Compare commits
19 Commits
69733e8d4c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ef10039c | ||
|
|
a9aefed4be | ||
|
|
08d645e572 | ||
|
|
ed352f5a71 | ||
|
|
8faaf38a08 | ||
|
|
c4cd2f97e9 | ||
|
|
8b0ada3cc0 | ||
|
|
81b5d3f4d8 | ||
|
|
467b8510a7 | ||
|
|
05e20501a9 | ||
|
|
381b7a8d60 | ||
|
|
c7af3b3e44 | ||
|
|
61c61a77a2 | ||
|
|
9292ef8d8d | ||
|
|
602ce92418 | ||
|
|
1384bb1d4a | ||
|
|
b8648c2861 | ||
|
|
c34b63b8da | ||
|
|
7b1961894d |
3
.gitignore
vendored
@@ -6,3 +6,6 @@ __pycache__/
|
||||
.idea/
|
||||
*.log
|
||||
.env
|
||||
create_repo.py
|
||||
*.mp4
|
||||
.node_modules/
|
||||
106
BACKEND_API_DOC.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 后端接口文档 (Backend API Documentation)
|
||||
|
||||
## 1. 概述 (Overview)
|
||||
|
||||
本接口文档描述了系统的后端API接口,主要包含 CRM(小程序业务)模块和 System(系统管理)模块。
|
||||
|
||||
- **基础路径 (Base URL)**: `http://localhost:8000/api` (开发环境)
|
||||
- **认证方式 (Authentication)**:
|
||||
- 管理后台:JWT (JSON Web Token)。Header: `Authorization: Bearer <token>`
|
||||
- 小程序:自定义Token。Header: `Authorization: Bearer <token>` (登录后获取)
|
||||
|
||||
## 2. 通用响应格式 (Common Response Format)
|
||||
|
||||
接口通常返回 JSON 格式数据,结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200, // 状态码,200 表示成功
|
||||
"msg": "success", // 提示信息
|
||||
"data": { ... } // 业务数据
|
||||
}
|
||||
```
|
||||
|
||||
## 3. CRM 模块 (Mini Program & Business)
|
||||
|
||||
该模块主要用于微信小程序业务及后台对业务数据的管理。
|
||||
路径前缀:`/api/`
|
||||
|
||||
### 3.1 用户认证 (Authentication)
|
||||
|
||||
| 接口路径 | 方法 | 描述 | 参数示例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `/auth/login/` | POST | 小程序登录,换取Token | `{ "code": "js_code" }` |
|
||||
|
||||
### 3.2 用户信息 (User Profile)
|
||||
|
||||
| 接口路径 | 方法 | 描述 | 参数/说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `/user/` | GET | 获取当前用户信息 | Header需带Token |
|
||||
| `/user/phone/` | POST | 更新/绑定手机号 | `{ "code": "phone_code" }` |
|
||||
| `/user-coupons/` | GET | 获取当前用户的优惠券 | |
|
||||
| `/user-projects/` | GET | 获取当前用户的项目 | |
|
||||
| `/user-honors/` | GET | 获取当前用户的荣誉 | |
|
||||
|
||||
### 3.3 业务资源 (Resources)
|
||||
|
||||
支持标准的 CRUD 操作。
|
||||
|
||||
| 资源名称 | 路径 | 方法 | 描述 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **项目 (Projects)** | `/projects/` | GET, POST, PUT, DELETE | 学位项目、活动等 |
|
||||
| **分类 (Categories)** | `/categories/` | GET, POST, PUT, DELETE | 项目分类 |
|
||||
| **教师 (Teachers)** | `/teachers/` | GET, POST, PUT, DELETE | 师资力量 |
|
||||
| **教学中心 (Centers)** | `/teaching-centers/` | GET, POST, PUT, DELETE | 教学点 |
|
||||
| **优惠券 (Coupons)** | `/coupons/` | GET, POST, PUT, DELETE | 优惠券定义 |
|
||||
| **可领优惠券** | `/available-coupons/` | GET | 获取可领取的优惠券列表 |
|
||||
| **轮播图 (Banners)** | `/banners/` | GET, POST, PUT, DELETE | 首页轮播图 |
|
||||
| **学员 (Students)** | `/students/` | GET, POST, PUT, DELETE | 学员信息管理 |
|
||||
| **学员项目** | `/student-projects/` | GET, POST, PUT, DELETE | 学员报名的项目 |
|
||||
| **学员优惠券** | `/student-coupons/` | GET, POST, PUT, DELETE | 学员持有的优惠券 |
|
||||
| **学员荣誉** | `/student-honors/` | GET, POST, PUT, DELETE | 学员获得的荣誉 |
|
||||
| **精彩展示** | `/student-showcases/` | GET, POST, PUT, DELETE | 视频或图片展示 |
|
||||
| **通知 (Notifications)**| `/notifications/` | GET, POST, PUT, DELETE | 系统通知 |
|
||||
|
||||
### 3.4 仪表盘 (Dashboard)
|
||||
|
||||
| 接口路径 | 方法 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `/dashboard/stats/` | GET | 获取后台首页统计数据 |
|
||||
|
||||
## 4. System 模块 (System Management)
|
||||
|
||||
该模块用于后台系统权限、用户及配置管理。
|
||||
路径前缀:`/api/system/`
|
||||
|
||||
| 资源名称 | 路径 | 方法 | 描述 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **系统用户** | `/user/` | GET, POST, PUT, DELETE | 后台管理员 |
|
||||
| **角色** | `/role/` | GET, POST, PUT, DELETE | 角色与权限组 |
|
||||
| **权限** | `/permission/` | GET, POST, PUT, DELETE | 菜单及按钮权限 |
|
||||
| **组织架构** | `/organization/` | GET, POST, PUT, DELETE | 部门管理 |
|
||||
| **岗位** | `/position/` | GET, POST, PUT, DELETE | 岗位管理 |
|
||||
| **字典** | `/dict/` | GET, POST, PUT, DELETE | 数据字典 |
|
||||
| **文件** | `/file/` | POST, DELETE | 文件上传与管理 |
|
||||
|
||||
## 5. 使用示例 (Usage Examples)
|
||||
|
||||
### 获取项目列表 (Get Projects)
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
GET /api/projects/?page=1&size=10 HTTP/1.1
|
||||
Authorization: Bearer <your_token>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"count": 100,
|
||||
"next": "...",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{ "id": 1, "title": "MBA项目", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
26
CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 更新日志 (Changelog)
|
||||
|
||||
本项目的所有显著更改都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),并且本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
- 完善项目文档结构,增加 `CONTRIBUTING.md` 和 `CHANGELOG.md`。
|
||||
- 更新 `README.md` 和 `STARTUP.md`,增加对 Node.js 高版本环境的支持说明。
|
||||
|
||||
### 修复
|
||||
- 修正 `INTERFACE_CONFIG.md` 中关于前端代理配置的描述,使其与代码实际逻辑一致。
|
||||
|
||||
## [1.0.0] - 2023-12-XX
|
||||
|
||||
### 初始发布
|
||||
- **Web 管理端**: 基于 Vue.js + Element UI 的后台管理系统。
|
||||
- **后端服务**: 基于 Django + DRF 的 API 服务。
|
||||
- **移动端**: 微信小程序支持。
|
||||
- **核心功能**:
|
||||
- RBAC 权限管理
|
||||
- 工作流引擎
|
||||
- CRM 客户关系管理
|
||||
- 系统监控与日志
|
||||
35
CONTRIBUTING.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 贡献指南 (Contributing Guide)
|
||||
|
||||
感谢你对 GeminiWX 项目感兴趣!我们欢迎任何形式的贡献,包括提交 Bug 报告、改进文档、提出新功能建议或直接提交代码。
|
||||
|
||||
## 1. 提交 Issue
|
||||
|
||||
在提交 Issue 之前,请先搜索现有的 Issues,避免重复提交。
|
||||
|
||||
- **Bug 报告**: 请详细描述 Bug 的重现步骤、预期行为和实际行为,并提供相关的日志或截图。
|
||||
- **功能建议**: 请清晰地描述你想要的功能以及它解决了什么问题。
|
||||
|
||||
## 2. 代码开发规范
|
||||
|
||||
本项目遵循一定的代码规范,请在提交代码前仔细阅读。
|
||||
|
||||
- **详细规范**: 请参考 [admin/specification.md](./admin/specification.md)
|
||||
- **后端 (Python/Django)**: 遵循 PEP 8 规范。
|
||||
- **前端 (Vue.js)**: 遵循 Vue 风格指南,保持代码整洁。
|
||||
|
||||
## 3. 提交 Pull Request (PR)
|
||||
|
||||
1. **Fork** 本仓库到你的个人账号。
|
||||
2. **Clone** 你的 Fork 到本地。
|
||||
3. 创建一个新的分支进行开发:`git checkout -b feature/your-feature-name` 或 `git checkout -b fix/your-bug-fix`。
|
||||
4. 提交你的更改:`git commit -m "feat: add new feature"` (推荐遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范)。
|
||||
5. Push 到你的远程仓库:`git push origin feature/your-feature-name`。
|
||||
6. 在 GitHub 上提交 Pull Request。
|
||||
|
||||
## 4. 开发环境搭建
|
||||
|
||||
请参考 [STARTUP.md](./STARTUP.md) 快速搭建开发环境。
|
||||
|
||||
## 5. 许可证
|
||||
|
||||
参与本项目即表示你同意遵守本项目的开源许可证。
|
||||
158
FRONTEND_API_DOC.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 前端接口文档 (Frontend API Documentation)
|
||||
|
||||
本文档说明如何在前端项目(微信小程序和Web管理后台)中使用接口。
|
||||
|
||||
## 1. 微信小程序 (WeChat Mini Program)
|
||||
|
||||
小程序目录:`wechat-mini-program/`
|
||||
|
||||
### 1.1 请求工具 (Request Utility)
|
||||
|
||||
封装文件:`utils/request.js`
|
||||
|
||||
该工具自动处理了:
|
||||
- Base URL 拼接
|
||||
- Token 注入 (Bearer Token)
|
||||
- 响应拦截与错误处理
|
||||
- 响应数据解包 (Unwrapping `data` field)
|
||||
|
||||
### 1.2 使用方法 (Usage)
|
||||
|
||||
在页面或组件中引入 `request` 方法:
|
||||
|
||||
```javascript
|
||||
const { request } = require('../../utils/request')
|
||||
// 或者在某些地方通过 app 获取 (如果挂载了)
|
||||
// const app = getApp()
|
||||
```
|
||||
|
||||
**示例代码:**
|
||||
|
||||
```javascript
|
||||
// GET 请求
|
||||
request({
|
||||
url: '/projects/',
|
||||
method: 'GET',
|
||||
data: { page: 1, page_size: 10 }
|
||||
}).then(res => {
|
||||
console.log('项目列表:', res.results)
|
||||
}).catch(err => {
|
||||
console.error('获取失败:', err)
|
||||
})
|
||||
|
||||
// POST 请求
|
||||
request({
|
||||
url: '/student-projects/',
|
||||
method: 'POST',
|
||||
data: {
|
||||
project: 1,
|
||||
status: 'enrolled'
|
||||
}
|
||||
}).then(res => {
|
||||
wx.showToast({ title: '报名成功' })
|
||||
})
|
||||
```
|
||||
|
||||
### 1.3 常用接口封装
|
||||
|
||||
虽然可以直接使用 `request`,但建议在 `api/` 目录下(如果存在)或各页面 JS 中进行简单封装。
|
||||
|
||||
目前主要直接在 `pages/` 对应的 `.js` 文件中调用。例如 `pages/index/index.js` 调用 `/banners/` 和 `/projects/`。
|
||||
|
||||
---
|
||||
|
||||
## 2. Web 管理后台 (Admin Client)
|
||||
|
||||
Web 端目录:`admin/client/`
|
||||
技术栈:Vue.js + Element UI
|
||||
|
||||
### 2.1 接口目录结构
|
||||
|
||||
所有 API 请求定义在 `src/api/` 目录下,按模块划分:
|
||||
|
||||
- `src/api/crm.js`: 核心业务接口(项目、学员、教师等)
|
||||
- `src/api/system/user.js`: 系统用户接口
|
||||
- `src/api/login.js`: 登录相关接口
|
||||
|
||||
### 2.2 定义接口 (Defining APIs)
|
||||
|
||||
以 `src/api/crm.js` 为例:
|
||||
|
||||
```javascript
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取教师列表
|
||||
export function getTeachers(query) {
|
||||
return request({
|
||||
url: '/teachers/',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 创建教师
|
||||
export function createTeacher(data) {
|
||||
return request({
|
||||
url: '/teachers/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 在组件中使用 (Usage in Components)
|
||||
|
||||
在 Vue 组件中引入并调用:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { getTeachers, createTeacher } from '@/api/crm'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
query: { page: 1, limit: 20 }
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
getTeachers(this.query).then(response => {
|
||||
this.list = response.data.results
|
||||
// 注意:根据后端响应拦截器的配置,response 可能已经是 data 部分,
|
||||
// 或者需要访问 response.data。请参考 src/utils/request.js
|
||||
})
|
||||
},
|
||||
handleAdd() {
|
||||
createTeacher(this.form).then(() => {
|
||||
this.$message.success('创建成功')
|
||||
this.fetchData()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2.4 请求拦截器 (Interceptors)
|
||||
|
||||
位置:`src/utils/request.js`
|
||||
|
||||
- **Request Interceptor**: 自动添加 `Authorization: Bearer <token>`
|
||||
- **Response Interceptor**:
|
||||
- 统一处理非 200 状态码
|
||||
- 统一处理业务错误码
|
||||
- 登录过期自动登出
|
||||
|
||||
## 3. 注意事项 (Notes)
|
||||
|
||||
1. **Base URL 配置**:
|
||||
- 小程序:`wechat-mini-program/config/env.js` (或 `app.js`)
|
||||
- Web 后台:`.env.development` 和 `.env.production` 中的 `VUE_APP_BASE_API`
|
||||
|
||||
2. **分页 (Pagination)**:
|
||||
- 默认参数:`page` (页码), `limit` 或 `size` (每页数量)
|
||||
- 响应格式:`{ count: Total, results: [Array] }`
|
||||
@@ -51,7 +51,8 @@ ALLOWED_HOSTS = ['*'] # 允许所有域名访问,生产环境建议修改为
|
||||
```javascript
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.5.81:8000', // 目标服务器地址
|
||||
// 目标服务器地址,优先使用环境变量 PROXY_TARGET,默认为 localhost
|
||||
target: process.env.PROXY_TARGET || 'http://127.0.0.1:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
153
README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# GeminiWX
|
||||
|
||||
GeminiWX 是一个基于 RBAC (Role-Based Access Control) 模型权限控制的中小型应用基础开发平台。项目采用前后端分离架构,集成了 Web 管理端、后端 API 服务以及移动端(微信小程序/H5),提供了一套功能完善、易于扩展的后台管理系统解决方案。
|
||||
|
||||
## 📖 项目概述
|
||||
|
||||
本项目旨在为中小型应用提供快速开发的基础平台,核心功能包括:
|
||||
- **Web 管理端**: 基于 Vue.js + Element UI 的现代化后台管理界面。
|
||||
- **后端 API 服务**: 基于 Django + DRF 的 RESTful API 服务,支持异步任务和完善的权限控制。
|
||||
- **移动端**: 支持微信小程序及 H5。
|
||||
|
||||
## <20> 运行环境与版本要求
|
||||
|
||||
为了确保项目能够正常运行,请确保您的开发环境满足以下版本要求:
|
||||
|
||||
### 后端环境 (Server)
|
||||
- **Python**: 3.8+ (推荐 3.8/3.9/3.10)
|
||||
- **Redis**: 5.0+
|
||||
- **Database**:
|
||||
- 开发环境: SQLite 3 (默认)
|
||||
- 生产环境: PostgreSQL 12+ (推荐)
|
||||
|
||||
### 前端环境 (Web Client)
|
||||
- **Node.js**:
|
||||
- 推荐版本: 14.x 或 16.x (LTS)
|
||||
- 兼容版本: 17+ (需要设置 `NODE_OPTIONS=--openssl-legacy-provider`,本项目 `package.json` 脚本中已预置该配置)
|
||||
- **npm**: 6.x+
|
||||
|
||||
### 移动端环境 (Mobile)
|
||||
- **HBuilderX**: 最新稳定版 (用于 Uni-app 开发)
|
||||
- **微信开发者工具**: 最新稳定版
|
||||
|
||||
## <20>🛠 技术栈
|
||||
|
||||
### 后端 (Server)
|
||||
- **核心框架**: Django 3.2.23
|
||||
- **API 框架**: Django REST Framework (DRF) 3.12.4
|
||||
- **异步任务**: Celery 5.2.7 + Redis
|
||||
- **认证机制**: JWT (djangorestframework-simplejwt)
|
||||
- **接口文档**: Swagger (drf-yasg)
|
||||
- **数据库**: SQLite (默认/开发) / PostgreSQL (生产建议)
|
||||
|
||||
### 前端 (Web Client)
|
||||
- **核心框架**: Vue.js 2.6.10
|
||||
- **UI 组件库**: Element UI 2.15.14
|
||||
- **状态管理**: Vuex 3.1.0
|
||||
- **路由管理**: Vue Router 3.0.6
|
||||
- **工具**: Axios, D3.js, xlsx
|
||||
|
||||
### 移动端 (Mobile)
|
||||
- **框架**: Uni-app (基于 Vue.js) / 原生微信小程序
|
||||
- **UI 库**: uView UI
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
- **系统管理**:
|
||||
- 组织架构、用户、角色、岗位管理
|
||||
- 菜单及按钮级别的功能权限控制
|
||||
- 灵活的数据权限控制
|
||||
- 数据字典与文件管理
|
||||
- **客户关系管理 (CRM)**: 客户、商机等基础 CRM 功能。
|
||||
- **工作流 (Workflow)**: 基于 loonflow 引擎简化的工作流,支持自定义流程定义与流转。
|
||||
- **系统监控**: 服务状态监控、Celery 定时任务管理、审计日志记录。
|
||||
|
||||
## 📂 目录结构
|
||||
|
||||
```text
|
||||
geminiWX/
|
||||
├── admin/
|
||||
│ ├── client/ # Web 前端项目源码 (Vue + ElementUI)
|
||||
│ ├── server/ # 后端项目源码 (Django)
|
||||
│ └── ...
|
||||
├── wechat-mini-program/ # 微信小程序源码
|
||||
├── INTERFACE_CONFIG.md # 接口配置文档
|
||||
├── PROJECT_INTRODUCTION.md # 项目详细介绍
|
||||
├── STARTUP.md # 启动指南
|
||||
└── README.md # 项目说明文档
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 启动后端服务 (Django)
|
||||
|
||||
1. 进入 `admin/server` 目录。
|
||||
2. 建议使用项目自带的虚拟环境(如果存在)或创建新的虚拟环境。
|
||||
* **使用自带环境 (Windows)**:
|
||||
```powershell
|
||||
.\admin\server\.venv\Scripts\python.exe admin/server/manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
* **手动配置环境**:
|
||||
```bash
|
||||
cd admin/server
|
||||
# 创建并激活虚拟环境
|
||||
python -m venv venv
|
||||
# Windows 激活
|
||||
.\venv\Scripts\activate
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
# 迁移数据库
|
||||
python manage.py migrate
|
||||
# 启动服务
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
3. 访问 `http://localhost:8000/` 确认服务运行正常。
|
||||
|
||||
### 2. 启动管理后台 (Vue.js)
|
||||
|
||||
1. 进入前端目录:
|
||||
```bash
|
||||
cd admin/client
|
||||
```
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
# 如果遇到依赖冲突,尝试使用:
|
||||
# npm install --legacy-peer-deps
|
||||
```
|
||||
3. 启动开发服务器:
|
||||
* **Windows (PowerShell)**:
|
||||
```powershell
|
||||
# 如果 Node 版本 >= 17,需要设置 NODE_OPTIONS
|
||||
$env:NODE_OPTIONS="--openssl-legacy-provider"
|
||||
npm run dev
|
||||
```
|
||||
* **Linux / macOS**:
|
||||
```bash
|
||||
# 如果 Node 版本 >= 17
|
||||
export NODE_OPTIONS=--openssl-legacy-provider
|
||||
npm run dev
|
||||
```
|
||||
* **通用**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
4. 启动完成后,浏览器会自动打开 `http://localhost:9528`。
|
||||
|
||||
### 3. 启动微信小程序
|
||||
|
||||
1. 下载并安装 [微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)。
|
||||
2. 打开工具,选择“导入项目”。
|
||||
3. 选择项目根目录下的 `wechat-mini-program` 文件夹。
|
||||
4. 设置 AppID(或使用测试号),编译并预览。
|
||||
|
||||
## 📄 相关文档
|
||||
|
||||
更多详细信息请参考以下文档:
|
||||
- **[项目介绍 (PROJECT_INTRODUCTION.md)](./PROJECT_INTRODUCTION.md)**: 详细的技术架构、功能模块说明。
|
||||
- **[启动文档 (STARTUP.md)](./STARTUP.md)**: 详细的安装和启动步骤。
|
||||
- **[接口配置 (INTERFACE_CONFIG.md)](./INTERFACE_CONFIG.md)**: 接口相关配置说明。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目遵循 MIT 许可证。
|
||||
22
STARTUP.md
@@ -52,9 +52,25 @@
|
||||
|
||||
4. 启动开发服务器:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
> **注意**: 如果你的 Node.js 版本 >= 17,可能会遇到 OpenSSL 相关的错误。请在启动前设置环境变量。
|
||||
|
||||
* **Windows (PowerShell)**:
|
||||
```powershell
|
||||
$env:NODE_OPTIONS="--openssl-legacy-provider"
|
||||
npm run dev
|
||||
```
|
||||
|
||||
* **CMD**:
|
||||
```cmd
|
||||
set NODE_OPTIONS=--openssl-legacy-provider
|
||||
npm run dev
|
||||
```
|
||||
|
||||
* **Linux / macOS**:
|
||||
```bash
|
||||
export NODE_OPTIONS=--openssl-legacy-provider
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. 启动完成后,浏览器会自动打开 `http://localhost:9528` (或其他配置的端口)。
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ JWT认证,可使用simple_history实现审计功能,支持swagger
|
||||
|
||||
安装node.js
|
||||
|
||||
安装依赖包 `npm install --registry=https://registry.npmmirror.com`
|
||||
安装依赖包 `npm install --registry=https://registry.npmmirror.com` (若报错尝试 `--legacy-peer-deps`)
|
||||
|
||||
运行服务 `npm run dev`
|
||||
运行服务 `npm run dev` (Node >= 17 需设置 `NODE_OPTIONS=--openssl-legacy-provider`)
|
||||
|
||||
### nginx
|
||||
本地跑时修改nginx.conf,可显示资源文件
|
||||
|
||||
1
admin/client/public/china.json
Normal file
@@ -358,3 +358,67 @@ export function deleteShowcase(id) {
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export function getNotifications(query) {
|
||||
return request({
|
||||
url: '/notifications/',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotification(data) {
|
||||
return request({
|
||||
url: '/notifications/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNotification(id, data) {
|
||||
return request({
|
||||
url: `/notifications/${id}/`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNotification(id) {
|
||||
return request({
|
||||
url: `/notifications/${id}/`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// Notification Batches
|
||||
export function getNotificationBatches(query) {
|
||||
return request({
|
||||
url: '/notification-batches/',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotificationBatch(data) {
|
||||
return request({
|
||||
url: '/notification-batches/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getBatchRecipients(id) {
|
||||
return request({
|
||||
url: `/notification-batches/${id}/recipients/`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNotificationBatch(id) {
|
||||
return request({
|
||||
url: `/notification-batches/${id}/`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,12 @@ export function clearFiles() {
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceUrl(data) {
|
||||
return request({
|
||||
url: '/file/replace_url/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,35 @@
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch-wrapper">
|
||||
<div class="theme-switch">
|
||||
<div
|
||||
class="theme-item"
|
||||
:class="{ active: theme === 'dark' }"
|
||||
@click="setTheme('dark')"
|
||||
>
|
||||
<span class="color-dot dark-dot"></span>
|
||||
深色
|
||||
</div>
|
||||
<div
|
||||
class="theme-item"
|
||||
:class="{ active: theme === 'orange' }"
|
||||
@click="setTheme('orange')"
|
||||
>
|
||||
<span class="color-dot orange-dot"></span>
|
||||
橙色
|
||||
</div>
|
||||
<div
|
||||
class="theme-item"
|
||||
:class="{ active: theme === 'light' }"
|
||||
@click="setTheme('light')"
|
||||
>
|
||||
<span class="color-dot light-dot"></span>
|
||||
浅白色
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,13 +82,17 @@ export default {
|
||||
...mapGetters([
|
||||
'sidebar',
|
||||
'avatar',
|
||||
'name'
|
||||
'name',
|
||||
'theme'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
toggleSideBar() {
|
||||
this.$store.dispatch('app/toggleSideBar')
|
||||
},
|
||||
setTheme(theme) {
|
||||
this.$store.dispatch('settings/setTheme', theme)
|
||||
},
|
||||
async logout() {
|
||||
await this.$store.dispatch('user/logout')
|
||||
this.$router.push(`/login?redirect=${this.$route.fullPath}`)
|
||||
@@ -150,5 +183,56 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switch-wrapper {
|
||||
float: right;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 3px;
|
||||
border-radius: 20px;
|
||||
|
||||
.theme-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #303133;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dark-dot { background: #1b2735; }
|
||||
.orange-dot { background: linear-gradient(135deg, #f6d365 0%, #fda085 100%); }
|
||||
.light-dot { background: #f0f2f5; border: 1px solid #ccc; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,41 +42,41 @@ export default {
|
||||
}
|
||||
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: #2b333e;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
|
||||
& .sidebar-logo-link {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: var(--menuBg);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
|
||||
& .sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 12px;
|
||||
& .sidebar-logo-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
& .sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: var(--menuText);
|
||||
font-weight: 600;
|
||||
line-height: 50px;
|
||||
font-size: 14px;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
& .sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 50px;
|
||||
font-size: 14px;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
vertical-align: middle;
|
||||
&.collapse {
|
||||
.sidebar-logo {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse {
|
||||
.sidebar-logo {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="{'has-logo':showLogo}">
|
||||
<div :class="{'has-logo':showLogo}" :style="cssVars">
|
||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
@@ -22,14 +22,14 @@
|
||||
import { mapGetters } from 'vuex'
|
||||
import Logo from './Logo'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import variables from '@/styles/variables.scss'
|
||||
|
||||
export default {
|
||||
components: { SidebarItem, Logo },
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'permission_routes',
|
||||
'sidebar'
|
||||
'sidebar',
|
||||
'theme'
|
||||
]),
|
||||
activeMenu() {
|
||||
const route = this.$route
|
||||
@@ -43,8 +43,47 @@ export default {
|
||||
showLogo() {
|
||||
return this.$store.state.settings.sidebarLogo
|
||||
},
|
||||
cssVars() {
|
||||
switch (this.theme) {
|
||||
case 'orange':
|
||||
return {
|
||||
'--menuBg': '#3e2723',
|
||||
'--menuHover': '#4e342e',
|
||||
'--subMenuBg': '#4e342e',
|
||||
'--subMenuHover': '#5d4037',
|
||||
'--menuText': '#ffecb3',
|
||||
'--menuActiveText': '#ff9800',
|
||||
'--subMenuActiveText': '#ff9800'
|
||||
}
|
||||
case 'light':
|
||||
return {
|
||||
'--menuBg': '#ffffff',
|
||||
'--menuHover': '#f5f5f5',
|
||||
'--subMenuBg': '#ffffff',
|
||||
'--subMenuHover': '#f5f5f5',
|
||||
'--menuText': '#333333',
|
||||
'--menuActiveText': '#409EFF',
|
||||
'--subMenuActiveText': '#303133'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
'--menuBg': '#2b333e',
|
||||
'--menuHover': '#1f262f',
|
||||
'--subMenuBg': '#1f262f',
|
||||
'--subMenuHover': '#001528',
|
||||
'--menuText': '#bfcbd9',
|
||||
'--menuActiveText': '#409EFF',
|
||||
'--subMenuActiveText': '#f4f4f5'
|
||||
}
|
||||
}
|
||||
},
|
||||
variables() {
|
||||
return variables
|
||||
const vars = this.cssVars
|
||||
return {
|
||||
menuBg: vars['--menuBg'],
|
||||
menuText: vars['--menuText'],
|
||||
menuActiveText: vars['--menuActiveText']
|
||||
}
|
||||
},
|
||||
isCollapse() {
|
||||
return !this.sidebar.opened
|
||||
|
||||
@@ -119,16 +119,46 @@ export const asyncRoutes = [
|
||||
meta: { title: '教学中心', icon: 'user' }
|
||||
},
|
||||
{
|
||||
path: 'coupons',
|
||||
name: 'Coupons',
|
||||
component: () => import('@/views/crm/coupon'),
|
||||
meta: { title: '优惠券管理', icon: 'money' }
|
||||
path: 'users',
|
||||
name: 'CrmUsers',
|
||||
component: () => import('@/views/crm/index'),
|
||||
redirect: '/crm/users/students',
|
||||
meta: { title: '用户管理', icon: 'peoples' },
|
||||
children: [
|
||||
{
|
||||
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: 'issued-coupons',
|
||||
name: 'IssuedCoupons',
|
||||
component: () => import('@/views/crm/issued_coupon'),
|
||||
meta: { title: '已发优惠券', icon: 'list' }
|
||||
path: 'coupon-manage',
|
||||
name: 'CouponManage',
|
||||
component: () => import('@/views/crm/index'),
|
||||
redirect: '/crm/coupon-manage/settings',
|
||||
meta: { title: '优惠券管理', icon: 'money' },
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Coupons',
|
||||
component: () => import('@/views/crm/coupon'),
|
||||
meta: { title: '优惠券设置', icon: 'money' }
|
||||
},
|
||||
{
|
||||
path: 'issued',
|
||||
name: 'IssuedCoupons',
|
||||
component: () => import('@/views/crm/issued_coupon'),
|
||||
meta: { title: '已发优惠券', icon: 'list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'banners',
|
||||
@@ -136,23 +166,44 @@ export const asyncRoutes = [
|
||||
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: 'notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('@/views/crm/index'),
|
||||
redirect: '/crm/notifications/history',
|
||||
meta: { title: '小程序通知', icon: 'message' },
|
||||
children: [
|
||||
{
|
||||
path: 'history',
|
||||
name: 'NotificationHistory',
|
||||
component: () => import('@/views/crm/notification/history'),
|
||||
meta: { title: '发送记录' }
|
||||
},
|
||||
{
|
||||
path: 'custom',
|
||||
name: 'NotificationCustom',
|
||||
component: () => import('@/views/crm/notification/custom'),
|
||||
meta: { title: '自定义发送' }
|
||||
},
|
||||
{
|
||||
path: 'project',
|
||||
name: 'NotificationProject',
|
||||
component: () => import('@/views/crm/notification/project'),
|
||||
meta: { title: '按项目发送' }
|
||||
},
|
||||
{
|
||||
path: 'coupon',
|
||||
name: 'NotificationCoupon',
|
||||
component: () => import('@/views/crm/notification/coupon'),
|
||||
meta: { title: '按优惠券发送' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ const getters = {
|
||||
avatar: state => state.user.avatar,
|
||||
name: state => state.user.name,
|
||||
perms: state => state.user.perms,
|
||||
permission_routes: state => state.permission.routes
|
||||
permission_routes: state => state.permission.routes,
|
||||
theme: state => state.settings.theme
|
||||
}
|
||||
export default getters
|
||||
|
||||
@@ -5,7 +5,8 @@ const { showSettings, fixedHeader, sidebarLogo } = defaultSettings
|
||||
const state = {
|
||||
showSettings: showSettings,
|
||||
fixedHeader: fixedHeader,
|
||||
sidebarLogo: sidebarLogo
|
||||
sidebarLogo: sidebarLogo,
|
||||
theme: 'dark'
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
@@ -13,12 +14,18 @@ const mutations = {
|
||||
if (state.hasOwnProperty(key)) {
|
||||
state[key] = value
|
||||
}
|
||||
},
|
||||
SET_THEME: (state, theme) => {
|
||||
state.theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
const actions = {
|
||||
changeSetting({ commit }, data) {
|
||||
commit('CHANGE_SETTING', data)
|
||||
},
|
||||
setTheme({ commit }, theme) {
|
||||
commit('SET_THEME', theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.sidebar-container {
|
||||
transition: width 0.28s;
|
||||
width: $sideBarWidth !important;
|
||||
background-color: $menuBg;
|
||||
background-color: var(--menuBg);
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
font-size: 0px;
|
||||
@@ -67,21 +67,21 @@
|
||||
.submenu-title-noDropdown,
|
||||
.el-submenu__title {
|
||||
&:hover {
|
||||
background-color: $menuHover !important;
|
||||
background-color: var(--menuHover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.is-active>.el-submenu__title {
|
||||
color: $subMenuActiveText !important;
|
||||
color: var(--subMenuActiveText) !important;
|
||||
}
|
||||
|
||||
& .nest-menu .el-submenu>.el-submenu__title,
|
||||
& .el-submenu .el-menu-item {
|
||||
min-width: $sideBarWidth !important;
|
||||
background-color: $subMenuBg !important;
|
||||
background-color: var(--subMenuBg) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: $subMenuHover !important;
|
||||
background-color: var(--subMenuHover) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,45 @@ service.interceptors.response.use(
|
||||
*/
|
||||
response => {
|
||||
const res = response.data
|
||||
|
||||
// Replace localhost/127.0.0.1 in response data with current window location hostname if needed
|
||||
// This helps when accessing admin panel from LAN but backend returns localhost URLs
|
||||
const replaceUrl = (data) => {
|
||||
if (!data) return data
|
||||
if (typeof data === 'string') {
|
||||
// Replace http://127.0.0.1:8000 or http://localhost:8000
|
||||
// with http://<current-host>:8000
|
||||
// We assume backend is on port 8000.
|
||||
// If we are proxying, we might want to replace with relative path or proxy target.
|
||||
// But simpler is to just replace the IP part with current window hostname if we are in dev/lan.
|
||||
// However, hardcoding 8000 might be risky if backend port changes.
|
||||
// Let's stick to replacing specific localhost/127.0.0.1:8000 patterns.
|
||||
const currentHost = window.location.hostname;
|
||||
// Only replace if current host is NOT localhost/127.0.0.1 (meaning we are on LAN)
|
||||
if (currentHost !== 'localhost' && currentHost !== '127.0.0.1') {
|
||||
return data.replace(/https?:\/\/(localhost|127\.0\.0\.1):8000/g, `http://${currentHost}:8000`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(replaceUrl)
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
Object.keys(data).forEach(key => {
|
||||
data[key] = replaceUrl(data[key])
|
||||
})
|
||||
return data
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Apply replacement
|
||||
try {
|
||||
replaceUrl(res);
|
||||
} catch (e) {
|
||||
console.error('Failed to replace URLs', e);
|
||||
}
|
||||
|
||||
if(res.code>=200 && res.code<400){
|
||||
return res
|
||||
}
|
||||
|
||||
276
admin/client/src/views/crm/notification.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="filter-container">
|
||||
<el-input v-model="listQuery.search" placeholder="标题/内容" style="width: 200px;" class="filter-item" @keyup.enter.native="handleFilter" />
|
||||
<el-button class="filter-item" type="primary" icon="el-icon-search" @click="handleFilter">搜索</el-button>
|
||||
<el-button class="filter-item" style="margin-left: 10px;" type="primary" icon="el-icon-edit" @click="handleCreate">新增</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="listLoading" :data="list" border fit highlight-current-row style="width: 100%;">
|
||||
<el-table-column label="ID" align="center" width="80">
|
||||
<template slot-scope="{row}"><span>{{ row.id }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标题" min-width="150px">
|
||||
<template slot-scope="{row}"><span>{{ row.title }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接收学员" width="120px" align="center">
|
||||
<template slot-scope="{row}"><span>{{ row.student }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag :type="row.notification_type | typeFilter">{{ row.notification_type | typeLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag :type="row.is_read ? 'success' : 'info'">{{ row.is_read ? '已读' : '未读' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" width="160px" align="center">
|
||||
<template slot-scope="{row}"><span>{{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="230">
|
||||
<template slot-scope="{row,$index}">
|
||||
<el-button type="primary" size="mini" @click="handleUpdate(row)">编辑</el-button>
|
||||
<el-button size="mini" type="danger" @click="handleDelete(row,$index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total>0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.limit" @pagination="getList" />
|
||||
|
||||
<el-dialog :title="textMap[dialogStatus]" :visible.sync="dialogFormVisible">
|
||||
<el-form ref="dataForm" :rules="rules" :model="temp" label-position="left" label-width="90px" style="width: 400px; margin-left:50px;">
|
||||
<el-form-item label="接收学员" prop="student">
|
||||
<el-select v-model="temp.student" filterable remote :remote-method="searchStudents" :loading="studentLoading" placeholder="请输入学员姓名搜索" style="width: 100%">
|
||||
<el-option v-for="item in studentOptions" :key="item.id" :label="item.name + ' (' + (item.phone || '-') + ')'" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="temp.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input v-model="temp.content" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="notification_type">
|
||||
<el-select v-model="temp.notification_type" placeholder="请选择类型" style="width: 100%">
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="活动提醒" value="activity" />
|
||||
<el-option label="课程通知" value="course" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否已读" prop="is_read">
|
||||
<el-switch v-model="temp.is_read" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="dialogStatus==='create'?createData():updateData()">确认</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getNotifications, createNotification, updateNotification, deleteNotification, getStudents } from '@/api/crm'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import { parseTime } from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'NotificationTable',
|
||||
components: { Pagination },
|
||||
filters: {
|
||||
typeFilter(type) {
|
||||
const statusMap = {
|
||||
system: 'info',
|
||||
activity: 'success',
|
||||
course: 'warning'
|
||||
}
|
||||
return statusMap[type]
|
||||
},
|
||||
typeLabel(type) {
|
||||
const labelMap = {
|
||||
system: '系统通知',
|
||||
activity: '活动提醒',
|
||||
course: '课程通知'
|
||||
}
|
||||
return labelMap[type] || type
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
list: null,
|
||||
total: 0,
|
||||
listLoading: true,
|
||||
listQuery: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
search: undefined
|
||||
},
|
||||
studentOptions: [],
|
||||
studentLoading: false,
|
||||
temp: {
|
||||
id: undefined,
|
||||
student: undefined,
|
||||
title: '',
|
||||
content: '',
|
||||
notification_type: 'system',
|
||||
is_read: false
|
||||
},
|
||||
dialogFormVisible: false,
|
||||
dialogStatus: '',
|
||||
textMap: {
|
||||
update: '编辑',
|
||||
create: '新增'
|
||||
},
|
||||
rules: {
|
||||
student: [{ required: true, message: '请选择学员', trigger: 'change' }],
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入内容', trigger: 'blur' }],
|
||||
notification_type: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
// Pre-fetch some students
|
||||
this.searchStudents('')
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.listLoading = true
|
||||
getNotifications(this.listQuery).then(response => {
|
||||
// Assume DRF DefaultRouter with pagination returns { count: ..., results: ... }
|
||||
// or if wrapped: { data: { count: ..., results: ... } }
|
||||
// Based on other views (e.g. Student), it seems pagination is used.
|
||||
// Let's check NotificationViewSet. It inherits ModelViewSet.
|
||||
// If pagination is not disabled, it returns paginated response.
|
||||
|
||||
// Checking existing code, e.g. StudentTable, it uses response.data.items or response.data.results?
|
||||
// Let's assume standard DRF pagination structure or the wrapper used in this project.
|
||||
// Looking at student.vue:
|
||||
// this.list = response.data.items
|
||||
// this.total = response.data.total
|
||||
// Wait, let me check the wrapper.
|
||||
|
||||
// If backend returns standard DRF: { count: 100, results: [...] }
|
||||
// If wrapped: { code: 200, data: { items: [...], total: ... } } ?
|
||||
|
||||
// Let's check `request.js` or `StudentViewSet` pagination.
|
||||
// `admin/server/utils/pagination.py` might be used.
|
||||
|
||||
// Assuming the response structure is consistent with other lists.
|
||||
// If I look at `student.vue` again (I read it earlier):
|
||||
// It imports `getStudents`.
|
||||
// `getList` calls `getStudents`.
|
||||
// But I didn't see the implementation of `getList` in `student.vue` fully in previous `Read` call (it was cut off).
|
||||
|
||||
// I'll assume standard structure for now:
|
||||
if (response.data && response.data.results) {
|
||||
this.list = response.data.results
|
||||
this.total = response.data.count
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
this.list = response.data
|
||||
this.total = response.data.length
|
||||
} else {
|
||||
// Fallback
|
||||
this.list = response.data
|
||||
this.total = 0
|
||||
}
|
||||
this.listLoading = false
|
||||
})
|
||||
},
|
||||
searchStudents(query) {
|
||||
this.studentLoading = true
|
||||
getStudents({ search: query, page: 1, limit: 20 }).then(response => {
|
||||
if (response.data && response.data.results) {
|
||||
this.studentOptions = response.data.results
|
||||
} else {
|
||||
this.studentOptions = response.data
|
||||
}
|
||||
this.studentLoading = false
|
||||
})
|
||||
},
|
||||
handleFilter() {
|
||||
this.listQuery.page = 1
|
||||
this.getList()
|
||||
},
|
||||
resetTemp() {
|
||||
this.temp = {
|
||||
id: undefined,
|
||||
student: undefined,
|
||||
title: '',
|
||||
content: '',
|
||||
notification_type: 'system',
|
||||
is_read: false
|
||||
}
|
||||
},
|
||||
handleCreate() {
|
||||
this.resetTemp()
|
||||
this.dialogStatus = 'create'
|
||||
this.dialogFormVisible = true
|
||||
this.$nextTick(() => {
|
||||
this.$refs['dataForm'].clearValidate()
|
||||
})
|
||||
},
|
||||
createData() {
|
||||
this.$refs['dataForm'].validate((valid) => {
|
||||
if (valid) {
|
||||
createNotification(this.temp).then(() => {
|
||||
this.dialogFormVisible = false
|
||||
this.$notify({
|
||||
title: 'Success',
|
||||
message: '创建成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.getList()
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.temp = Object.assign({}, row) // copy obj
|
||||
this.dialogStatus = 'update'
|
||||
this.dialogFormVisible = true
|
||||
this.$nextTick(() => {
|
||||
this.$refs['dataForm'].clearValidate()
|
||||
})
|
||||
},
|
||||
updateData() {
|
||||
this.$refs['dataForm'].validate((valid) => {
|
||||
if (valid) {
|
||||
const tempData = Object.assign({}, this.temp)
|
||||
updateNotification(tempData.id, tempData).then(() => {
|
||||
this.dialogFormVisible = false
|
||||
this.$notify({
|
||||
title: 'Success',
|
||||
message: '更新成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.getList()
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
handleDelete(row, index) {
|
||||
this.$confirm('确认删除?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
deleteNotification(row.id).then(() => {
|
||||
this.$notify({
|
||||
title: 'Success',
|
||||
message: '删除成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.list.splice(index, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
101
admin/client/src/views/crm/notification/coupon.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入通知标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="通知类型" prop="notification_type">
|
||||
<el-select v-model="form.notification_type">
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="活动提醒" value="activity" />
|
||||
<el-option label="课程通知" value="course" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择优惠券" prop="coupon_id">
|
||||
<el-select v-model="form.target_criteria.coupon_id" filterable placeholder="请选择优惠券">
|
||||
<el-option v-for="item in couponOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="使用状态">
|
||||
<el-select v-model="form.target_criteria.status" clearable placeholder="全部状态">
|
||||
<el-option label="已领取(未使用)" value="assigned" />
|
||||
<el-option label="已使用" value="used" />
|
||||
<el-option label="已过期" value="expired" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createNotificationBatch, getCoupons } from '@/api/crm'
|
||||
|
||||
export default {
|
||||
name: 'NotificationCoupon',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
content: '',
|
||||
notification_type: 'activity',
|
||||
send_mode: 'coupon',
|
||||
target_criteria: {
|
||||
coupon_id: undefined,
|
||||
status: 'assigned'
|
||||
}
|
||||
},
|
||||
couponOptions: [],
|
||||
submitting: false,
|
||||
rules: {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchCoupons()
|
||||
},
|
||||
methods: {
|
||||
fetchCoupons() {
|
||||
getCoupons({ page_size: 1000 }).then(response => {
|
||||
this.couponOptions = response.data.results
|
||||
})
|
||||
},
|
||||
onSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (valid) {
|
||||
if (!this.form.target_criteria.coupon_id) {
|
||||
this.$message.warning('请选择优惠券');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
createNotificationBatch(this.form).then(response => {
|
||||
this.$notify({
|
||||
title: '成功',
|
||||
message: '通知发送成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.submitting = false
|
||||
this.$router.push('/crm/notifications/history')
|
||||
}).catch(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
125
admin/client/src/views/crm/notification/custom.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入通知标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="通知类型" prop="notification_type">
|
||||
<el-select v-model="form.notification_type">
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="活动提醒" value="activity" />
|
||||
<el-option label="课程通知" value="course" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="发送对象">
|
||||
<el-radio-group v-model="sendType">
|
||||
<el-radio label="select">选择学员</el-radio>
|
||||
<el-radio label="all">全员发送</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="sendType === 'select'" label="选择学员">
|
||||
<el-select
|
||||
v-model="form.target_criteria.student_ids"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="请输入学员姓名或电话搜索"
|
||||
:remote-method="remoteMethod"
|
||||
:loading="loading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in studentOptions"
|
||||
:key="item.id"
|
||||
:label="item.name + ' (' + item.phone + ')'"
|
||||
:value="item.id">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createNotificationBatch, getStudents } from '@/api/crm'
|
||||
|
||||
export default {
|
||||
name: 'NotificationCustom',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
content: '',
|
||||
notification_type: 'system',
|
||||
send_mode: 'custom',
|
||||
target_criteria: {
|
||||
student_ids: [],
|
||||
select_all: false
|
||||
}
|
||||
},
|
||||
sendType: 'select',
|
||||
studentOptions: [],
|
||||
loading: false,
|
||||
submitting: false,
|
||||
rules: {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
sendType(val) {
|
||||
this.form.target_criteria.select_all = (val === 'all')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
remoteMethod(query) {
|
||||
if (query !== '') {
|
||||
this.loading = true;
|
||||
getStudents({ search: query, page_size: 20 }).then(response => {
|
||||
this.studentOptions = response.data.results;
|
||||
this.loading = false;
|
||||
})
|
||||
} else {
|
||||
this.studentOptions = [];
|
||||
}
|
||||
},
|
||||
onSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (valid) {
|
||||
if (this.sendType === 'select' && this.form.target_criteria.student_ids.length === 0) {
|
||||
this.$message.warning('请选择至少一名学员');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
createNotificationBatch(this.form).then(response => {
|
||||
this.$notify({
|
||||
title: '成功',
|
||||
message: '通知发送成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.submitting = false
|
||||
this.$router.push('/crm/notifications/history')
|
||||
}).catch(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
163
admin/client/src/views/crm/notification/history.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="filter-container">
|
||||
<el-input v-model="listQuery.search" placeholder="标题搜索" style="width: 200px;" class="filter-item" @keyup.enter.native="handleFilter" />
|
||||
<el-button class="filter-item" type="primary" icon="el-icon-search" @click="handleFilter">
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
:data="list"
|
||||
border
|
||||
fit
|
||||
highlight-current-row
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-table-column label="序号" align="center" width="80">
|
||||
<template slot-scope="{row, $index}">
|
||||
<span>{{ (listQuery.page - 1) * listQuery.limit + $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标题" min-width="150px">
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" min-width="200px" show-overflow-tooltip>
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.content }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送方式" width="120px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag>{{ row.send_mode_display }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知类型" width="120px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag type="info">{{ row.notification_type_display }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接收人数" width="100px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.recipient_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" width="160px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width">
|
||||
<template slot-scope="{row}">
|
||||
<el-button type="primary" size="mini" @click="handleViewRecipients(row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button type="danger" size="mini" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total>0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.limit" @pagination="getList" />
|
||||
|
||||
<el-dialog :title="textMap[dialogStatus]" :visible.sync="dialogFormVisible" width="80%">
|
||||
<el-table :data="recipientList" border fit highlight-current-row style="width: 100%;" v-loading="recipientLoading">
|
||||
<el-table-column property="student_name" label="姓名" width="120"></el-table-column>
|
||||
<el-table-column property="student_phone" label="电话" width="120"></el-table-column>
|
||||
<el-table-column property="teaching_center" label="教学中心" width="150"></el-table-column>
|
||||
<el-table-column property="project_info" label="关联项目/优惠券" min-width="150"></el-table-column>
|
||||
<el-table-column property="status_info" label="状态" width="100"></el-table-column>
|
||||
<el-table-column label="是否已读" width="100" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag :type="row.is_read ? 'success' : 'info'">{{ row.is_read ? '已读' : '未读' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogFormVisible = false">
|
||||
关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getNotificationBatches, getBatchRecipients, deleteNotificationBatch } from '@/api/crm'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import { parseTime } from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'NotificationHistory',
|
||||
components: { Pagination },
|
||||
filters: {
|
||||
parseTime
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
list: null,
|
||||
total: 0,
|
||||
listLoading: true,
|
||||
listQuery: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
search: undefined
|
||||
},
|
||||
dialogFormVisible: false,
|
||||
dialogStatus: '',
|
||||
textMap: {
|
||||
view: '发送详情'
|
||||
},
|
||||
recipientList: [],
|
||||
recipientLoading: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.listLoading = true
|
||||
getNotificationBatches(this.listQuery).then(response => {
|
||||
this.list = response.data.results
|
||||
this.total = response.data.count
|
||||
this.listLoading = false
|
||||
})
|
||||
},
|
||||
handleFilter() {
|
||||
this.listQuery.page = 1
|
||||
this.getList()
|
||||
},
|
||||
handleViewRecipients(row) {
|
||||
this.dialogStatus = 'view'
|
||||
this.dialogFormVisible = true
|
||||
this.recipientLoading = true
|
||||
getBatchRecipients(row.id).then(response => {
|
||||
this.recipientList = response.data
|
||||
this.recipientLoading = false
|
||||
})
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$confirm('确认删除该条通知记录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
deleteNotificationBatch(row.id).then(() => {
|
||||
this.$notify({
|
||||
title: '成功',
|
||||
message: '删除成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
103
admin/client/src/views/crm/notification/project.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入通知标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="通知类型" prop="notification_type">
|
||||
<el-select v-model="form.notification_type">
|
||||
<el-option label="系统通知" value="system" />
|
||||
<el-option label="活动提醒" value="activity" />
|
||||
<el-option label="课程通知" value="course" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择项目" prop="project_id">
|
||||
<el-select v-model="form.target_criteria.project_id" filterable placeholder="请选择项目">
|
||||
<el-option v-for="item in projectOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="学员状态">
|
||||
<el-checkbox-group v-model="form.target_criteria.statuses">
|
||||
<el-checkbox label="enrolled">报名</el-checkbox>
|
||||
<el-checkbox label="studying">在读</el-checkbox>
|
||||
<el-checkbox label="graduated">毕业</el-checkbox>
|
||||
<el-checkbox label="completed">完成</el-checkbox>
|
||||
<el-checkbox label="quit">退学</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createNotificationBatch, getProjects } from '@/api/crm'
|
||||
|
||||
export default {
|
||||
name: 'NotificationProject',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
title: '',
|
||||
content: '',
|
||||
notification_type: 'course',
|
||||
send_mode: 'project',
|
||||
target_criteria: {
|
||||
project_id: undefined,
|
||||
statuses: ['enrolled', 'studying', 'graduated']
|
||||
}
|
||||
},
|
||||
projectOptions: [],
|
||||
submitting: false,
|
||||
rules: {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchProjects()
|
||||
},
|
||||
methods: {
|
||||
fetchProjects() {
|
||||
getProjects({ page_size: 1000 }).then(response => {
|
||||
this.projectOptions = response.data.results
|
||||
})
|
||||
},
|
||||
onSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (valid) {
|
||||
if (!this.form.target_criteria.project_id) {
|
||||
this.$message.warning('请选择项目');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
createNotificationBatch(this.form).then(response => {
|
||||
this.$notify({
|
||||
title: '成功',
|
||||
message: '通知发送成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.submitting = false
|
||||
this.$router.push('/crm/notifications/history')
|
||||
}).catch(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
<template slot-scope="{row}"><span>{{ row.teacher_name }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="封面" width="80px" align="center">
|
||||
<template slot-scope="{row}"><img :src="row.image" width="50" height="50" style="object-fit:cover"></template>
|
||||
<template slot-scope="{row}"><img :src="resolveUrl(row.image)" width="50" height="50" style="object-fit:cover"></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学习人数" width="80px" align="center">
|
||||
<template slot-scope="{row}"><span>{{ row.students }}</span></template>
|
||||
@@ -92,7 +92,7 @@
|
||||
:show-file-list="false"
|
||||
:on-success="handleAvatarSuccess"
|
||||
:headers="upHeaders">
|
||||
<img v-if="temp.image" :src="temp.image" class="avatar">
|
||||
<img v-if="temp.image" :src="resolveUrl(temp.image)" class="avatar">
|
||||
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
@@ -362,6 +362,16 @@ export default {
|
||||
detail: ''
|
||||
}
|
||||
},
|
||||
resolveUrl(url) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://localhost:8000/media/')) {
|
||||
return url.replace('http://localhost:8000', '')
|
||||
}
|
||||
if (url.startsWith('http://127.0.0.1:8000/media/')) {
|
||||
return url.replace('http://127.0.0.1:8000', '')
|
||||
}
|
||||
return url
|
||||
},
|
||||
handleCreate() {
|
||||
this.resetTemp()
|
||||
this.dialogStatus = 'create'
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<span>{{ row.age }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="城市" width="100px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.city || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已报项目" align="center" min-width="200">
|
||||
<template slot-scope="{row}">
|
||||
<div v-if="row.enrolled_projects && row.enrolled_projects.length > 0" style="text-align: left;">
|
||||
@@ -115,6 +120,9 @@
|
||||
<el-form-item label="年龄" prop="age">
|
||||
<el-input v-model.number="temp.age" />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市" prop="city">
|
||||
<el-input v-model="temp.city" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="temp.address" />
|
||||
</el-form-item>
|
||||
@@ -219,6 +227,7 @@ export default {
|
||||
name: '',
|
||||
phone: '',
|
||||
age: undefined,
|
||||
city: '',
|
||||
address: '',
|
||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||
openid: '',
|
||||
@@ -276,6 +285,7 @@ export default {
|
||||
name: '',
|
||||
phone: '',
|
||||
age: undefined,
|
||||
city: '',
|
||||
address: '',
|
||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||
openid: '',
|
||||
|
||||
@@ -20,11 +20,15 @@ export default {
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
default: '350px'
|
||||
},
|
||||
chartData: {
|
||||
type: Object,
|
||||
default: () => ({ names: [], counts: [], title: '热门机构' })
|
||||
default: () => ({ names: [], counts: [], title: '机构学员' })
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'dark'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -32,6 +36,17 @@ export default {
|
||||
chart: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theme() {
|
||||
this.initChart()
|
||||
},
|
||||
chartData: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.initChart()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initChart()
|
||||
@@ -46,13 +61,28 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose() // Dispose old instance to apply new theme completely if needed, or just setOption
|
||||
}
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
const names = this.chartData.names || []
|
||||
const counts = this.chartData.counts || []
|
||||
const title = this.chartData.title || '热门机构'
|
||||
const title = this.chartData.title || '机构学员'
|
||||
|
||||
const isDark = this.theme === 'dark';
|
||||
const textColor = isDark ? '#fff' : '#333';
|
||||
const axisLineColor = isDark ? '#fff' : '#333';
|
||||
const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
this.chart.setOption({
|
||||
title: {
|
||||
text: title,
|
||||
textStyle: {
|
||||
color: textColor
|
||||
},
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { // 坐标轴指示器,坐标轴触发有效
|
||||
@@ -60,7 +90,7 @@ export default {
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
top: 40,
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '3%',
|
||||
@@ -71,12 +101,34 @@ export default {
|
||||
data: names,
|
||||
axisTick: {
|
||||
alignWithLabel: true
|
||||
},
|
||||
axisLabel: {
|
||||
color: axisLineColor
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: axisLineColor
|
||||
}
|
||||
}
|
||||
}],
|
||||
yAxis: [{
|
||||
type: 'value',
|
||||
minInterval: 1, // Ensure integer ticks
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: axisLineColor
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: axisLineColor
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: splitLineColor
|
||||
}
|
||||
}
|
||||
}],
|
||||
series: [{
|
||||
@@ -85,7 +137,34 @@ export default {
|
||||
stack: 'vistors',
|
||||
barWidth: '60%',
|
||||
data: counts,
|
||||
animationDuration: 6000
|
||||
animationDuration: 6000,
|
||||
itemStyle: {
|
||||
color: function(params) {
|
||||
const colorList = [
|
||||
['#4facfe', '#00f2fe'],
|
||||
['#43e97b', '#38f9d7'],
|
||||
['#fa709a', '#fee140'],
|
||||
['#a18cd1', '#fbc2eb'],
|
||||
['#ff9a9e', '#fecfef'],
|
||||
['#667eea', '#764ba2'],
|
||||
['#f093fb', '#f5576c'],
|
||||
['#8ec5fc', '#e0c3fc']
|
||||
];
|
||||
const index = params.dataIndex % colorList.length;
|
||||
const color = colorList[index];
|
||||
return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: color[0] },
|
||||
{ offset: 1, color: color[1] }
|
||||
]);
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0] // Rounded top
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0,0,0,0.3)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ export default {
|
||||
chartData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'dark'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -37,6 +41,9 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theme() {
|
||||
this.setOptions(this.chartData)
|
||||
},
|
||||
chartData: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
@@ -58,16 +65,31 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose()
|
||||
}
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
this.setOptions(this.chartData)
|
||||
},
|
||||
setOptions({ expectedData, actualData, xAxis } = {}) {
|
||||
const isDark = this.theme === 'dark';
|
||||
const textColor = isDark ? '#fff' : '#333';
|
||||
const splitLineColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
this.chart.setOption({
|
||||
xAxis: {
|
||||
data: xAxis && xAxis.length ? xAxis : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
boundaryGap: false,
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: textColor
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
@@ -87,10 +109,26 @@ export default {
|
||||
yAxis: {
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: textColor
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: textColor
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: splitLineColor
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['上周', '本周']
|
||||
data: ['上周', '本周'],
|
||||
textStyle: {
|
||||
color: textColor
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '上周', itemStyle: {
|
||||
@@ -98,7 +136,9 @@ export default {
|
||||
color: '#FF005A',
|
||||
lineStyle: {
|
||||
color: '#FF005A',
|
||||
width: 2
|
||||
width: 3,
|
||||
shadowColor: 'rgba(255, 0, 90, 0.3)',
|
||||
shadowBlur: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,13 +154,27 @@ export default {
|
||||
type: 'line',
|
||||
itemStyle: {
|
||||
normal: {
|
||||
color: '#3888fa',
|
||||
color: '#00f2fe',
|
||||
lineStyle: {
|
||||
color: '#3888fa',
|
||||
width: 2
|
||||
color: '#00f2fe',
|
||||
width: 3,
|
||||
shadowColor: 'rgba(0, 242, 254, 0.3)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f3f8ff'
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0, color: 'rgba(0, 242, 254, 0.3)' // 0% 处的颜色
|
||||
}, {
|
||||
offset: 1, color: 'rgba(0, 242, 254, 0)' // 100% 处的颜色
|
||||
}],
|
||||
global: false // 缺省为 false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
187
admin/client/src/views/dashboard/components/MapChart.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div :class="className" :style="{height:height,width:width}" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
import resize from '../mixins/resize'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
mixins: [resize],
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: 'chart'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '700px'
|
||||
},
|
||||
chartData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'dark'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theme() {
|
||||
this.setOptions(this.chartData)
|
||||
},
|
||||
chartData: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.setOptions(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initChart()
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (!this.chart) {
|
||||
return
|
||||
}
|
||||
this.chart.dispose()
|
||||
this.chart = null
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose()
|
||||
}
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
// Check if map is already registered
|
||||
if (echarts.getMap('china')) {
|
||||
this.setOptions(this.chartData)
|
||||
} else {
|
||||
// Fetch China Map GeoJSON from local static assets
|
||||
// This ensures the map loads even without internet access or on non-local environments
|
||||
axios.get(process.env.BASE_URL + 'china.json')
|
||||
.then(response => {
|
||||
echarts.registerMap('china', response.data)
|
||||
this.setOptions(this.chartData)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load China map data:', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
setOptions(data) {
|
||||
if (!this.chart) return
|
||||
|
||||
const isDark = this.theme === 'dark';
|
||||
const textColor = isDark ? '#fff' : '#333';
|
||||
|
||||
let areaColor, borderColor, visualMapColor;
|
||||
|
||||
switch (this.theme) {
|
||||
case 'orange':
|
||||
areaColor = '#fdf6ec';
|
||||
borderColor = '#faecd8';
|
||||
visualMapColor = ['#fdf6ec', '#e6a23c'];
|
||||
break;
|
||||
case 'light':
|
||||
areaColor = '#f0f9eb';
|
||||
borderColor = '#fff';
|
||||
visualMapColor = ['#f0f9eb', '#67c23a'];
|
||||
break;
|
||||
default: // dark
|
||||
areaColor = 'rgba(20, 41, 87, 0.6)';
|
||||
borderColor = '#4facfe';
|
||||
visualMapColor = ['#e0ffff', '#006edd'];
|
||||
break;
|
||||
}
|
||||
|
||||
this.chart.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '学员城市分布',
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(params) {
|
||||
if (params.value) {
|
||||
return params.name + ': ' + params.value + '人';
|
||||
}
|
||||
return params.name;
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: data.length > 0 ? Math.max(...data.map(d => d.value)) : 100,
|
||||
left: '50',
|
||||
bottom: '50',
|
||||
text: ['高', '低'],
|
||||
textStyle: {
|
||||
color: textColor
|
||||
},
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: visualMapColor
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '学员分布',
|
||||
type: 'map',
|
||||
mapType: 'china',
|
||||
roam: true, // Allow zooming and panning
|
||||
zoom: 1.2,
|
||||
label: {
|
||||
show: true,
|
||||
color: textColor,
|
||||
fontSize: 10,
|
||||
formatter: function(params) {
|
||||
if (params.value > 0) {
|
||||
return params.name + '\n' + params.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: textColor
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#fbc2eb',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: areaColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: 1
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :class="'theme-' + theme">
|
||||
<el-row :gutter="40" class="panel-group">
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('students')">
|
||||
<div class="card-panel-icon-wrapper icon-people">
|
||||
<div class="card-panel card-panel-blue" @click="handleSetLineChartData('students')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="peoples" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('organizations')">
|
||||
<div class="card-panel-icon-wrapper icon-message">
|
||||
<div class="card-panel card-panel-purple" @click="handleSetLineChartData('organizations')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="education" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -30,8 +30,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper icon-money">
|
||||
<div class="card-panel card-panel-orange" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="component" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -43,8 +43,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-shopping">
|
||||
<div class="card-panel card-panel-green" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="money" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -60,8 +60,8 @@
|
||||
</el-row>
|
||||
<el-row :gutter="40" class="panel-group">
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper icon-money">
|
||||
<div class="card-panel card-panel-red" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="list" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -73,8 +73,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-banner">
|
||||
<div class="card-panel card-panel-yellow" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="tab" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -86,8 +86,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-video">
|
||||
<div class="card-panel card-panel-pink" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="star" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -104,11 +104,17 @@
|
||||
|
||||
<script>
|
||||
import CountTo from 'vue-count-to'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CountTo
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'theme'
|
||||
])
|
||||
},
|
||||
props: {
|
||||
panelData: {
|
||||
type: Object,
|
||||
@@ -148,96 +154,28 @@ export default {
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #666;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(0, 0, 0, .05);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-people {
|
||||
background: #40c9c6;
|
||||
box-shadow: 0 4px 12px rgba(64, 201, 198, 0.4);
|
||||
}
|
||||
|
||||
.icon-message {
|
||||
background: #36a3f7;
|
||||
box-shadow: 0 4px 12px rgba(54, 163, 247, 0.4);
|
||||
}
|
||||
|
||||
.icon-money {
|
||||
background: #f4516c;
|
||||
box-shadow: 0 4px 12px rgba(244, 81, 108, 0.4);
|
||||
}
|
||||
|
||||
.icon-shopping {
|
||||
background: #34bfa3;
|
||||
box-shadow: 0 4px 12px rgba(52, 191, 163, 0.4);
|
||||
}
|
||||
|
||||
.icon-banner {
|
||||
background: #ff9800;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.icon-video {
|
||||
background: #9c27b0;
|
||||
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-people {
|
||||
color: #40c9c6;
|
||||
background: rgba(64, 201, 198, 0.1);
|
||||
}
|
||||
|
||||
.icon-message {
|
||||
color: #36a3f7;
|
||||
background: rgba(54, 163, 247, 0.1);
|
||||
}
|
||||
|
||||
.icon-money {
|
||||
color: #f4516c;
|
||||
background: rgba(244, 81, 108, 0.1);
|
||||
}
|
||||
|
||||
.icon-shopping {
|
||||
color: #34bfa3;
|
||||
background: rgba(52, 191, 163, 0.1);
|
||||
}
|
||||
|
||||
.icon-banner {
|
||||
color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.icon-video {
|
||||
color: #9c27b0;
|
||||
background: rgba(156, 39, 176, 0.1);
|
||||
}
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
float: none;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
transition: all 0.38s ease-out;
|
||||
border-radius: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.card-panel-icon {
|
||||
float: left;
|
||||
font-size: 48px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.card-panel-description {
|
||||
@@ -250,18 +188,161 @@ export default {
|
||||
|
||||
.card-panel-text {
|
||||
line-height: 18px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-panel-num {
|
||||
font-size: 20px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme: Dark (Original Styles) */
|
||||
.theme-dark {
|
||||
.card-panel {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 250, 240, 0.1); /* 半透明米白色 */
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||
background-color: rgba(255, 250, 240, 0.15);
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
transform: scale(1.1);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.card-panel-icon {
|
||||
font-size: 32px;
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
.card-panel-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.card-panel-num {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Colors for icons in Dark Mode (since background is now uniform) */
|
||||
&.card-panel-blue {
|
||||
.card-panel-icon { color: #4facfe; }
|
||||
.card-panel-icon-wrapper { color: #4facfe; }
|
||||
}
|
||||
&.card-panel-purple {
|
||||
.card-panel-icon { color: #8fd3f4; } /* Lighter purple for dark mode visibility */
|
||||
.card-panel-icon-wrapper { color: #8fd3f4; }
|
||||
}
|
||||
&.card-panel-orange {
|
||||
.card-panel-icon { color: #ff9a9e; }
|
||||
.card-panel-icon-wrapper { color: #ff9a9e; }
|
||||
}
|
||||
&.card-panel-green {
|
||||
.card-panel-icon { color: #43e97b; }
|
||||
.card-panel-icon-wrapper { color: #43e97b; }
|
||||
}
|
||||
&.card-panel-red {
|
||||
.card-panel-icon { color: #fa709a; }
|
||||
.card-panel-icon-wrapper { color: #fa709a; }
|
||||
}
|
||||
&.card-panel-yellow {
|
||||
.card-panel-icon { color: #fbc2eb; }
|
||||
.card-panel-icon-wrapper { color: #fbc2eb; }
|
||||
}
|
||||
&.card-panel-pink {
|
||||
.card-panel-icon { color: #ff0844; }
|
||||
.card-panel-icon-wrapper { color: #ff0844; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme: Light & Orange */
|
||||
.theme-light, .theme-orange {
|
||||
.card-panel {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08); /* 更柔和的阴影 */
|
||||
border: 1px solid #e6ebf5; /* 增加边框 */
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px 0 rgba(0,0,0,.1);
|
||||
border-color: #dcdfe6;
|
||||
.card-panel-icon-wrapper {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
background: #f0f2f5; /* 稍微深一点的背景 */
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.card-panel-icon {
|
||||
font-size: 32px;
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
.card-panel-text {
|
||||
color: rgba(0, 0, 0, 0.65); /* 加深文字颜色 */
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-panel-num {
|
||||
color: #333; /* 加深数字颜色 */
|
||||
}
|
||||
|
||||
/* Specific Colors */
|
||||
&.card-panel-blue {
|
||||
.card-panel-icon { color: #36a3f7; }
|
||||
.card-panel-icon-wrapper { color: #e6f7ff; background: #e6f7ff; } /* 浅蓝背景 */
|
||||
&:hover .card-panel-icon-wrapper { background: #36a3f7; }
|
||||
}
|
||||
&.card-panel-purple {
|
||||
.card-panel-icon { color: #667eea; }
|
||||
.card-panel-icon-wrapper { color: #f2f6fc; background: #f2f6fc; }
|
||||
&:hover .card-panel-icon-wrapper { background: #667eea; }
|
||||
}
|
||||
&.card-panel-orange {
|
||||
.card-panel-icon { color: #f5576c; }
|
||||
.card-panel-icon-wrapper { color: #fef0f0; background: #fef0f0; }
|
||||
&:hover .card-panel-icon-wrapper { background: #f5576c; }
|
||||
}
|
||||
&.card-panel-green {
|
||||
.card-panel-icon { color: #38f9d7; }
|
||||
.card-panel-icon-wrapper { color: #f0f9eb; background: #f0f9eb; }
|
||||
&:hover .card-panel-icon-wrapper { background: #38f9d7; }
|
||||
}
|
||||
&.card-panel-red {
|
||||
.card-panel-icon { color: #fa709a; }
|
||||
.card-panel-icon-wrapper { color: #fef0f0; background: #fef0f0; }
|
||||
&:hover .card-panel-icon-wrapper { background: #fa709a; }
|
||||
}
|
||||
&.card-panel-yellow {
|
||||
.card-panel-icon { color: #fbc2eb; }
|
||||
.card-panel-icon-wrapper { color: #fdf6ec; background: #fdf6ec; }
|
||||
&:hover .card-panel-icon-wrapper { background: #fbc2eb; }
|
||||
}
|
||||
&.card-panel-pink {
|
||||
.card-panel-icon { color: #ff0844; }
|
||||
.card-panel-icon-wrapper { color: #fef0f0; background: #fef0f0; }
|
||||
&:hover .card-panel-icon-wrapper { background: #ff0844; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:550px) {
|
||||
.card-panel-description {
|
||||
display: block;
|
||||
@@ -272,6 +353,8 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
|
||||
.svg-icon {
|
||||
display: block;
|
||||
|
||||
@@ -20,11 +20,19 @@ export default {
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
default: '350px'
|
||||
},
|
||||
chartData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
legendData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'dark'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -32,6 +40,17 @@ export default {
|
||||
chart: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theme() {
|
||||
this.initChart()
|
||||
},
|
||||
chartData: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.initChart()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initChart()
|
||||
@@ -46,11 +65,22 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose()
|
||||
}
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
const legendData = (this.chartData || []).map(i => i.name)
|
||||
const isDark = this.theme === 'dark';
|
||||
const textColor = isDark ? '#fff' : '#333';
|
||||
|
||||
this.chart.setOption({
|
||||
title: {
|
||||
text: '项目人数',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: textColor
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)'
|
||||
@@ -58,7 +88,10 @@ export default {
|
||||
legend: {
|
||||
left: 'center',
|
||||
bottom: '10',
|
||||
data: legendData
|
||||
data: this.legendData && this.legendData.map(item => item.name),
|
||||
textStyle: {
|
||||
color: textColor
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -66,15 +99,27 @@ export default {
|
||||
type: 'pie',
|
||||
roseType: 'radius',
|
||||
radius: [15, 95],
|
||||
center: ['50%', '38%'],
|
||||
center: ['50%', '45%'],
|
||||
data: this.chartData || [],
|
||||
animationEasing: 'cubicInOut',
|
||||
animationDuration: 2600,
|
||||
label: {
|
||||
show: false
|
||||
show: true,
|
||||
formatter: '{b}',
|
||||
color: textColor
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: textColor
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
color: function(params) {
|
||||
// Custom bright colors
|
||||
const colorList = ['#37a2da', '#32c5e9', '#67e0e3', '#9fe6b8', '#ffdb5c', '#ff9f7f', '#fb7293', '#e062ae', '#e690d1', '#e7bcf3', '#9d96f5', '#8378ea', '#96bfff'];
|
||||
return colorList[params.dataIndex % colorList.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -83,3 +128,27 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.legend-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-container" :class="'theme-' + theme">
|
||||
<div v-if="loading" class="chart-wrapper">加载中...</div>
|
||||
<div v-else>
|
||||
<panel-group
|
||||
@@ -7,21 +7,21 @@
|
||||
@handleSetLineChartData="handleSetLineChartData"
|
||||
/>
|
||||
|
||||
<el-row style="background:#fff;padding:16px 16px 0;margin-bottom:32px;border-radius:12px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);">
|
||||
<line-chart :chart-data="lineChartData" />
|
||||
<el-row :gutter="32">
|
||||
<el-col :xs="24" :sm="24" :lg="12">
|
||||
<div class="chart-wrapper">
|
||||
<pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" :theme="theme" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :lg="12">
|
||||
<div class="chart-wrapper">
|
||||
<bar-chart :chart-data="barChartData" :theme="theme" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="32">
|
||||
<el-col :xs="24" :sm="24" :lg="8">
|
||||
<div class="chart-wrapper">
|
||||
<pie-chart :chart-data="pieChartData" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :lg="8">
|
||||
<div class="chart-wrapper">
|
||||
<bar-chart :chart-data="barChartData" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-row class="map-chart-wrapper">
|
||||
<map-chart :chart-data="mapChartData" :theme="theme" />
|
||||
</el-row>
|
||||
<div v-if="error" class="chart-wrapper">{{ error }}</div>
|
||||
</div>
|
||||
@@ -29,8 +29,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import PanelGroup from './components/PanelGroup'
|
||||
import LineChart from './components/LineChart'
|
||||
import MapChart from './components/MapChart'
|
||||
import PieChart from './components/PieChart'
|
||||
import BarChart from './components/BarChart'
|
||||
import { getDashboardStats } from '@/api/dashboard'
|
||||
@@ -39,14 +40,20 @@ export default {
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
PanelGroup,
|
||||
LineChart,
|
||||
MapChart,
|
||||
PieChart,
|
||||
BarChart
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'theme'
|
||||
])
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
error: '',
|
||||
mapChartData: [],
|
||||
lineChartData: {
|
||||
expectedData: [],
|
||||
actualData: [],
|
||||
@@ -63,8 +70,9 @@ export default {
|
||||
showcases_active: 0
|
||||
},
|
||||
pieChartData: [],
|
||||
pieChartLegend: [],
|
||||
barChartData: {
|
||||
title: '热门机构',
|
||||
title: '机构学员',
|
||||
names: [],
|
||||
counts: []
|
||||
}
|
||||
@@ -83,7 +91,9 @@ export default {
|
||||
this.allLineChartData = data.line_chart_data
|
||||
// Default to organizations line chart
|
||||
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
|
||||
this.mapChartData = data.map_chart_data
|
||||
this.pieChartData = data.pie_chart_data
|
||||
this.pieChartLegend = data.pie_chart_legend
|
||||
this.barChartData = data.bar_chart_data
|
||||
this.loading = false
|
||||
}).catch(error => {
|
||||
@@ -104,19 +114,80 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-container {
|
||||
padding: 32px;
|
||||
background-color: rgb(240, 242, 245);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.5s ease;
|
||||
|
||||
&.theme-dark {
|
||||
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
|
||||
}
|
||||
|
||||
&.theme-orange {
|
||||
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
|
||||
}
|
||||
|
||||
&.theme-light {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* Subtle animated background elements could be added here if needed, but gradient is good for now */
|
||||
|
||||
.chart-wrapper {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16px 16px 0;
|
||||
margin-bottom: 32px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.map-chart-wrapper {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16px 16px 0;
|
||||
margin-bottom: 32px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Theme Overrides for Light Theme */
|
||||
&.theme-light {
|
||||
.chart-wrapper, .map-chart-wrapper {
|
||||
background: #fff;
|
||||
border: 1px solid #e6ebf5;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: none;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.15);
|
||||
border-color: #dcdfe6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Overrides for Orange Theme (optional, if we want glass effect to be different) */
|
||||
&.theme-orange {
|
||||
.chart-wrapper, .map-chart-wrapper {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
@click="handleClear"
|
||||
size="small"
|
||||
>一键清空</el-button>
|
||||
<el-button
|
||||
class="filter-item"
|
||||
type="warning"
|
||||
icon="el-icon-edit"
|
||||
@click="handleReplaceUrl"
|
||||
size="small"
|
||||
>一键修改IP/URL</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
@@ -86,6 +93,21 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog title="一键修改IP/URL" :visible.sync="dialogFormVisible" width="30%">
|
||||
<el-form :model="temp" label-position="left" label-width="100px" style="width: 400px; margin-left:50px;">
|
||||
<el-form-item label="原IP/URL" prop="old_url">
|
||||
<el-input v-model="temp.old_url" placeholder="请输入原IP/URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新IP/URL" prop="new_url">
|
||||
<el-input v-model="temp.new_url" placeholder="请输入新IP/URL" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="dialogFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="replaceUrlData">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<pagination
|
||||
v-show="fileList.count>0"
|
||||
:total="fileList.count"
|
||||
@@ -96,7 +118,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { getFileList, deleteFile, clearFiles } from "@/api/file"
|
||||
import { getFileList, deleteFile, clearFiles, replaceUrl } from "@/api/file"
|
||||
import Pagination from "@/components/Pagination"
|
||||
export default {
|
||||
components: { Pagination },
|
||||
@@ -108,6 +130,11 @@ export default {
|
||||
page: 1,
|
||||
page_size: 20
|
||||
},
|
||||
dialogFormVisible: false,
|
||||
temp: {
|
||||
old_url: '',
|
||||
new_url: ''
|
||||
},
|
||||
enabledOptions: [
|
||||
{ key: "文档", display_name: "文档" },
|
||||
{ key: "图片", display_name: "图片" },
|
||||
@@ -175,6 +202,35 @@ export default {
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleReplaceUrl() {
|
||||
this.temp = {
|
||||
old_url: '',
|
||||
new_url: ''
|
||||
}
|
||||
this.dialogFormVisible = true
|
||||
},
|
||||
replaceUrlData() {
|
||||
if (!this.temp.old_url || !this.temp.new_url) {
|
||||
this.$message.error('请填写完整')
|
||||
return
|
||||
}
|
||||
this.$confirm('确认修改? 此操作不可恢复!', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
replaceUrl(this.temp).then(response => {
|
||||
this.$notify({
|
||||
title: 'Success',
|
||||
message: response.data.message || '修改成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
this.dialogFormVisible = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,7 +41,11 @@ module.exports = {
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://localhost:8000',
|
||||
target: process.env.PROXY_TARGET || 'http://127.0.0.1:8000',
|
||||
target: process.env.PROXY_TARGET || 'http://192.168.5.112:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/media': {
|
||||
target: process.env.PROXY_TARGET || 'http://192.168.5.112:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 49 KiB |
11
admin/server/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.vscode/
|
||||
.vs/
|
||||
dist/
|
||||
celerybeat.pid
|
||||
celerybeat-schedule.*
|
||||
db.sqlite3
|
||||
.env
|
||||
@@ -1,5 +1,20 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase, Notification, StudentProject
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'student', 'notification_type', 'is_read', 'created_at')
|
||||
list_filter = ('notification_type', 'is_read', 'created_at')
|
||||
search_fields = ('title', 'content', 'student__name', 'student__phone')
|
||||
readonly_fields = ('created_at',)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('student', 'title', 'content', 'notification_type')
|
||||
}),
|
||||
('状态', {
|
||||
'fields': ('is_read', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
@@ -38,16 +53,20 @@ class BannerAdmin(admin.ModelAdmin):
|
||||
|
||||
@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')
|
||||
list_display = ('name', 'phone', 'responsible_teacher', 'is_active', 'status', 'teaching_center', 'created_at')
|
||||
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name', 'responsible_teacher__name')
|
||||
list_filter = ('teaching_center', 'status', 'is_active', 'responsible_teacher')
|
||||
inlines = []
|
||||
|
||||
class StudentProjectInline(admin.TabularInline):
|
||||
model = StudentProject
|
||||
extra = 1
|
||||
|
||||
class StudentCouponInline(admin.TabularInline):
|
||||
model = StudentCoupon
|
||||
extra = 1
|
||||
|
||||
StudentAdmin.inlines = [StudentCouponInline]
|
||||
StudentAdmin.inlines = [StudentProjectInline, StudentCouponInline]
|
||||
|
||||
@admin.register(StudentCoupon)
|
||||
class StudentCouponAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import os
|
||||
import shutil
|
||||
import random
|
||||
import socket
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from apps.crm.models import Project, StudentShowcase, Teacher
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Populate database with business mock data using existing local images'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Get local IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
ip = '127.0.0.1'
|
||||
|
||||
self.base_url = f"http://{ip}:8000"
|
||||
self.stdout.write(f"Using Base URL: {self.base_url}")
|
||||
|
||||
self.media_root = settings.MEDIA_ROOT
|
||||
self.projects_dir = os.path.join(self.media_root, 'projects')
|
||||
self.showcases_dir = os.path.join(self.media_root, 'showcases')
|
||||
|
||||
# Ensure directories exist
|
||||
os.makedirs(self.projects_dir, exist_ok=True)
|
||||
os.makedirs(self.showcases_dir, exist_ok=True)
|
||||
|
||||
self.stdout.write('Starting population...')
|
||||
|
||||
# Identify available source images
|
||||
source_project_images = [f'project_{i}.jpg' for i in range(1, 9)]
|
||||
source_showcase_images = ['showcase_1.jpg', 'showcase_2.jpg']
|
||||
|
||||
# Verify at least one source image exists, otherwise warn
|
||||
if not os.path.exists(os.path.join(self.projects_dir, source_project_images[0])):
|
||||
self.stdout.write(self.style.WARNING("Source images (project_*.jpg) not found! Please ensure default images exist."))
|
||||
# Fallback to creating a basic one if absolutely nothing exists?
|
||||
# For now, let's assume they exist as seen in LS.
|
||||
|
||||
# Create a default teacher if needed
|
||||
teacher, _ = Teacher.objects.get_or_create(
|
||||
name="教务中心",
|
||||
defaults={'phone': '13800138000', 'bio': '负责课程教务管理'}
|
||||
)
|
||||
|
||||
# 1. Projects Data
|
||||
projects_data = [
|
||||
{
|
||||
'title': '工商管理博士 (DBA)',
|
||||
'project_type': 'training',
|
||||
'target_name': 'dba.jpg',
|
||||
'detail': 'DBA项目旨在培养具有全球视野和战略思维的商业领袖。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '工商管理硕士 (MBA)',
|
||||
'project_type': 'training',
|
||||
'target_name': 'mba.jpg',
|
||||
'detail': 'MBA项目提供全面的商业管理知识,提升管理能力。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '应用心理学硕士',
|
||||
'project_type': 'training',
|
||||
'target_name': 'psychology.jpg',
|
||||
'detail': '深入探索人类心理,应用于管理和生活中。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '人工智能与商业应用',
|
||||
'project_type': 'training',
|
||||
'target_name': 'ai.jpg',
|
||||
'detail': '掌握AI技术,赋能商业创新。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '高级工商管理博士 (EDBA)',
|
||||
'project_type': 'training',
|
||||
'target_name': 'edba.jpg',
|
||||
'detail': '为资深管理者量身定制的最高学位项目。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '2025秋季开学典礼',
|
||||
'project_type': 'competition',
|
||||
'target_name': 'opening_ceremony.jpg',
|
||||
'detail': '欢迎新同学加入我们的大家庭。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '2025届毕业典礼',
|
||||
'project_type': 'competition',
|
||||
'target_name': 'graduation.jpg',
|
||||
'detail': '祝贺各位同学顺利毕业,前程似锦。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '全球商业领袖论坛',
|
||||
'project_type': 'competition',
|
||||
'target_name': 'forum.jpg',
|
||||
'detail': '汇聚全球智慧,探讨商业未来。',
|
||||
'teacher': teacher
|
||||
},
|
||||
{
|
||||
'title': '校友会年度聚会',
|
||||
'project_type': 'grading',
|
||||
'target_name': 'alumni.jpg',
|
||||
'detail': '重温同窗情谊,共谋发展机遇。',
|
||||
'teacher': teacher
|
||||
}
|
||||
]
|
||||
|
||||
for i, p_data in enumerate(projects_data):
|
||||
# Cycle through available source images
|
||||
src_img_name = source_project_images[i % len(source_project_images)]
|
||||
local_path = self.copy_image('projects', src_img_name, 'projects', p_data['target_name'])
|
||||
|
||||
Project.objects.update_or_create(
|
||||
title=p_data['title'],
|
||||
defaults={
|
||||
'project_type': p_data['project_type'],
|
||||
'teacher': p_data['teacher'],
|
||||
'image': local_path,
|
||||
'detail': p_data['detail']
|
||||
}
|
||||
)
|
||||
self.stdout.write(f"Updated Project: {p_data['title']} with image {src_img_name}")
|
||||
|
||||
# 2. Exciting Videos (StudentShowcase)
|
||||
videos_data = [
|
||||
{
|
||||
'title': '人工智能课程精彩片段',
|
||||
'target_name': 'video_ai.jpg',
|
||||
'video_url': 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
'description': '课堂实录,感受AI魅力。'
|
||||
},
|
||||
{
|
||||
'title': 'DBA学员分享',
|
||||
'target_name': 'video_dba.jpg',
|
||||
'video_url': 'https://www.w3schools.com/html/movie.mp4',
|
||||
'description': '听听学长学姐怎么说。'
|
||||
},
|
||||
{
|
||||
'title': '毕业典礼回顾',
|
||||
'target_name': 'video_grad.jpg',
|
||||
'video_url': 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
'description': '难忘瞬间,感动常在。'
|
||||
}
|
||||
]
|
||||
|
||||
for i, v_data in enumerate(videos_data):
|
||||
src_img_name = source_showcase_images[i % len(source_showcase_images)]
|
||||
# Note: source showcases are in 'projects' folder based on LS output?
|
||||
# Wait, LS showed showcase_1.jpg in 'projects' folder!
|
||||
local_path = self.copy_image('projects', src_img_name, 'showcases', v_data['target_name'])
|
||||
|
||||
StudentShowcase.objects.update_or_create(
|
||||
title=v_data['title'],
|
||||
defaults={
|
||||
'cover_image': local_path,
|
||||
'video_url': v_data['video_url'],
|
||||
'description': v_data['description']
|
||||
}
|
||||
)
|
||||
self.stdout.write(f"Updated Video: {v_data['title']} with image {src_img_name}")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully populated business data using local images!'))
|
||||
|
||||
def copy_image(self, src_folder, src_filename, dest_folder, dest_filename):
|
||||
src_path = os.path.join(self.media_root, src_folder, src_filename)
|
||||
dest_path = os.path.join(self.media_root, dest_folder, dest_filename)
|
||||
relative_path = f"/media/{dest_folder}/{dest_filename}"
|
||||
|
||||
if not os.path.exists(src_path):
|
||||
self.stdout.write(self.style.WARNING(f"Source image not found: {src_path}"))
|
||||
return relative_path # Return path anyway, hoping it exists or will be fixed
|
||||
|
||||
try:
|
||||
shutil.copy2(src_path, dest_path)
|
||||
self.stdout.write(f"Copied {src_filename} to {dest_filename}")
|
||||
return relative_path
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Error copying image: {str(e)}"))
|
||||
return relative_path
|
||||
31
admin/server/apps/crm/migrations/0028_notification.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-09 03:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0027_coupon_is_time_limited'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='标题')),
|
||||
('content', models.TextField(verbose_name='内容')),
|
||||
('notification_type', models.CharField(choices=[('system', '系统通知'), ('activity', '活动提醒'), ('course', '课程通知')], default='system', max_length=20, verbose_name='通知类型')),
|
||||
('is_read', models.BooleanField(default=False, verbose_name='是否已读')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发送时间')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='crm.student', verbose_name='接收学员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '消息通知',
|
||||
'verbose_name_plural': '消息通知',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
28
admin/server/apps/crm/migrations/0029_auto_20251209_1134.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-09 03:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0028_notification'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='student',
|
||||
old_name='teacher',
|
||||
new_name='responsible_teacher',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='enrolled_projects',
|
||||
field=models.ManyToManyField(blank=True, related_name='enrolled_students', through='crm.StudentProject', to='crm.Project', verbose_name='已报名项目'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='是否活跃'),
|
||||
),
|
||||
]
|
||||
37
admin/server/apps/crm/migrations/0030_auto_20251209_1207.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-09 04:07
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0029_auto_20251209_1134'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationBatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='标题')),
|
||||
('content', models.TextField(verbose_name='内容')),
|
||||
('notification_type', models.CharField(choices=[('system', '系统通知'), ('activity', '活动提醒'), ('course', '课程通知')], default='system', max_length=20, verbose_name='通知类型')),
|
||||
('send_mode', models.CharField(choices=[('custom', '自定义发送'), ('project', '按项目发送'), ('coupon', '按优惠券发送')], default='custom', max_length=20, verbose_name='发送方式')),
|
||||
('target_criteria', models.TextField(default='{}', verbose_name='发送条件')),
|
||||
('recipient_count', models.IntegerField(default=0, verbose_name='接收人数')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发送时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '通知发送记录',
|
||||
'verbose_name_plural': '通知发送记录',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='batch',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='crm.notificationbatch', verbose_name='关联批次'),
|
||||
),
|
||||
]
|
||||
18
admin/server/apps/crm/migrations/0031_student_city.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-09 07:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0030_auto_20251209_1207'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='city',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='城市'),
|
||||
),
|
||||
]
|
||||
43
admin/server/apps/crm/migrations/0032_auto_20251212_1146.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-12 03:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0031_student_city'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='banner',
|
||||
name='image',
|
||||
field=models.CharField(max_length=500, verbose_name='图片URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='image',
|
||||
field=models.CharField(default='https://images.unsplash.com/photo-1526379095098-d400fd0bf935', max_length=500, verbose_name='封面图片URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='student',
|
||||
name='avatar',
|
||||
field=models.CharField(default='https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', max_length=500, verbose_name='微信头像'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='studenthonor',
|
||||
name='image',
|
||||
field=models.CharField(default='https://images.unsplash.com/photo-1579548122080-c35fd6820ecb', max_length=500, verbose_name='证书图片URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='studentshowcase',
|
||||
name='cover_image',
|
||||
field=models.CharField(max_length=500, verbose_name='封面图片URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='studentshowcase',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='视频链接URL'),
|
||||
),
|
||||
]
|
||||
@@ -49,7 +49,7 @@ class Project(models.Model):
|
||||
# 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")
|
||||
image = models.CharField(max_length=500, 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)
|
||||
@@ -106,7 +106,7 @@ class Coupon(models.Model):
|
||||
return self.title
|
||||
|
||||
class Banner(models.Model):
|
||||
image = models.URLField(verbose_name="图片URL")
|
||||
image = models.CharField(max_length=500, 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="排序")
|
||||
@@ -130,10 +130,14 @@ class Student(models.Model):
|
||||
# 用户需求是“微信唯一标识”,这通常指 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")
|
||||
responsible_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)
|
||||
city = models.CharField(max_length=50, verbose_name="城市", null=True, blank=True)
|
||||
# 已经有avatar字段,对应微信头像
|
||||
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
|
||||
avatar = models.CharField(max_length=500, verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否活跃")
|
||||
|
||||
enrolled_projects = models.ManyToManyField(Project, through='StudentProject', related_name='enrolled_students', verbose_name="已报名项目", blank=True)
|
||||
|
||||
# 新增字段
|
||||
teaching_center = models.ForeignKey(TeachingCenter, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联教学中心")
|
||||
@@ -251,7 +255,7 @@ def update_student_learning_count_on_delete(sender, instance, **kwargs):
|
||||
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")
|
||||
image = models.CharField(max_length=500, 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="创建时间")
|
||||
@@ -266,8 +270,8 @@ class StudentHonor(models.Model):
|
||||
|
||||
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)
|
||||
cover_image = models.CharField(max_length=500, verbose_name="封面图片URL")
|
||||
video_url = models.CharField(max_length=500, 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="排序")
|
||||
@@ -303,3 +307,58 @@ def activate_coupons_on_phone_bind(sender, instance, created, **kwargs):
|
||||
coupon=coupon,
|
||||
status='assigned'
|
||||
)
|
||||
|
||||
class NotificationBatch(models.Model):
|
||||
SEND_MODE_CHOICES = (
|
||||
('custom', '自定义发送'),
|
||||
('project', '按项目发送'),
|
||||
('coupon', '按优惠券发送'),
|
||||
)
|
||||
# Using choices from Notification class requires Notification to be defined,
|
||||
# but NotificationBatch is defined before Notification.
|
||||
# So I will redefine choices or use strings.
|
||||
TYPE_CHOICES = (
|
||||
('system', '系统通知'),
|
||||
('activity', '活动提醒'),
|
||||
('course', '课程通知'),
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
content = models.TextField(verbose_name="内容")
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型")
|
||||
send_mode = models.CharField(max_length=20, choices=SEND_MODE_CHOICES, default='custom', verbose_name="发送方式")
|
||||
# SQLite version issue with JSONField, using TextField with manual JSON handling
|
||||
target_criteria = models.TextField(verbose_name="发送条件", default="{}")
|
||||
recipient_count = 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
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.get_send_mode_display()})"
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = (
|
||||
('system', '系统通知'),
|
||||
('activity', '活动提醒'),
|
||||
('course', '课程通知'),
|
||||
)
|
||||
|
||||
student = models.ForeignKey('Student', on_delete=models.CASCADE, related_name='notifications', verbose_name="接收学员")
|
||||
batch = models.ForeignKey(NotificationBatch, on_delete=models.CASCADE, null=True, blank=True, related_name="notifications", verbose_name="关联批次")
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
content = models.TextField(verbose_name="内容")
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型")
|
||||
is_read = models.BooleanField(default=False, verbose_name="是否已读")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "消息通知"
|
||||
verbose_name_plural = verbose_name
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.student.name}"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch
|
||||
|
||||
class AbsoluteURLField(serializers.CharField):
|
||||
def to_representation(self, value):
|
||||
if not value:
|
||||
return value
|
||||
if value.startswith(('http://', 'https://')):
|
||||
return value
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(value)
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data and isinstance(data, str) and data.startswith(('http://', 'https://')):
|
||||
# If it's an absolute URL, try to extract the relative path
|
||||
# We assume standard media URL structure '/media/'
|
||||
if '/media/' in data:
|
||||
return '/media/' + data.split('/media/', 1)[1]
|
||||
return super().to_internal_value(data)
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -19,13 +38,24 @@ class TeachingCenterSerializer(serializers.ModelSerializer):
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
# category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
# category_color = serializers.CharField(source='category.color', read_only=True)
|
||||
category_name = serializers.CharField(source='get_project_type_display', read_only=True)
|
||||
category_color = serializers.SerializerMethodField()
|
||||
teacher_name = serializers.SerializerMethodField()
|
||||
project_type_display = serializers.CharField(source='get_project_type_display', read_only=True)
|
||||
image = AbsoluteURLField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = '__all__'
|
||||
|
||||
def get_category_color(self, obj):
|
||||
colors = {
|
||||
'training': 'bg-cyan-100 text-cyan-600',
|
||||
'competition': 'bg-purple-100 text-purple-600',
|
||||
'grading': 'bg-blue-100 text-blue-600'
|
||||
}
|
||||
return colors.get(obj.project_type, 'bg-gray-100 text-gray-600')
|
||||
|
||||
def get_teacher_name(self, obj):
|
||||
if obj.teacher:
|
||||
return obj.teacher.name
|
||||
@@ -62,21 +92,24 @@ class StudentCouponSerializer(serializers.ModelSerializer):
|
||||
|
||||
class BannerSerializer(serializers.ModelSerializer):
|
||||
project_title = serializers.CharField(source='project.title', read_only=True)
|
||||
image = AbsoluteURLField()
|
||||
class Meta:
|
||||
model = Banner
|
||||
fields = '__all__'
|
||||
|
||||
class StudentSerializer(serializers.ModelSerializer):
|
||||
teacher_name = serializers.CharField(source='teacher.name', read_only=True)
|
||||
teacher_name = serializers.CharField(source='responsible_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)
|
||||
teacher = serializers.PrimaryKeyRelatedField(source='responsible_teacher', queryset=Teacher.objects.all(), required=False, allow_null=True)
|
||||
avatar = AbsoluteURLField()
|
||||
|
||||
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']
|
||||
fields = ['id', 'name', 'phone', 'age', 'city', '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):
|
||||
@@ -101,7 +134,7 @@ 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_image = AbsoluteURLField(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)
|
||||
|
||||
@@ -112,10 +145,11 @@ class StudentProjectSerializer(serializers.ModelSerializer):
|
||||
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_avatar = AbsoluteURLField(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)
|
||||
image = AbsoluteURLField()
|
||||
|
||||
class Meta:
|
||||
model = StudentHonor
|
||||
@@ -123,7 +157,23 @@ class StudentHonorSerializer(serializers.ModelSerializer):
|
||||
|
||||
class StudentShowcaseSerializer(serializers.ModelSerializer):
|
||||
student_name = serializers.CharField(source='student.name', read_only=True)
|
||||
cover_image = AbsoluteURLField()
|
||||
video_url = AbsoluteURLField()
|
||||
|
||||
class Meta:
|
||||
model = StudentShowcase
|
||||
fields = '__all__'
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = '__all__'
|
||||
|
||||
class NotificationBatchSerializer(serializers.ModelSerializer):
|
||||
send_mode_display = serializers.CharField(source='get_send_mode_display', read_only=True)
|
||||
notification_type_display = serializers.CharField(source='get_notification_type_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NotificationBatch
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView, NotificationViewSet, NotificationBatchViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'categories', CategoryViewSet)
|
||||
@@ -14,6 +14,8 @@ router.register(r'student-coupons', StudentCouponViewSet)
|
||||
router.register(r'student-projects', StudentProjectViewSet)
|
||||
router.register(r'student-honors', StudentHonorViewSet)
|
||||
router.register(r'student-showcases', StudentShowcaseViewSet)
|
||||
router.register(r'notifications', NotificationViewSet, basename='notification')
|
||||
router.register(r'notification-batches', NotificationBatchViewSet, basename='notification-batch')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -4,10 +4,11 @@ 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
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch
|
||||
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer, NotificationSerializer, NotificationBatchSerializer
|
||||
import requests
|
||||
from django.db.models import Count, F
|
||||
import json
|
||||
from django.db.models import Count, F, Sum
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
@@ -181,28 +182,48 @@ class DashboardStatsView(APIView):
|
||||
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'))
|
||||
# 2. Pie Chart: Project Category Proportion
|
||||
# Aggregate student counts by project type
|
||||
projects_data = Project.objects.filter(is_active=True).values('project_type').annotate(total_students=Sum('students')).order_by('-total_students')
|
||||
|
||||
# Map type codes to display names
|
||||
# Define colors for each project type
|
||||
type_colors = {
|
||||
'training': '#36cfc9', # Cyan
|
||||
'competition': '#b37feb', # Purple
|
||||
'grading': '#409EFF', # Blue
|
||||
}
|
||||
|
||||
# Map type codes to display names for legend
|
||||
type_mapping = dict(Project.PROJECT_TYPE_CHOICES)
|
||||
|
||||
# Construct Legend Data
|
||||
pie_chart_legend = []
|
||||
for type_code, color in type_colors.items():
|
||||
pie_chart_legend.append({
|
||||
'name': type_mapping.get(type_code, type_code),
|
||||
'color': color
|
||||
})
|
||||
|
||||
pie_chart_data = []
|
||||
for item in project_types_data:
|
||||
for item in projects_data:
|
||||
type_code = item['project_type']
|
||||
name = type_mapping.get(type_code, type_code)
|
||||
# Use default color if type not found
|
||||
color = type_colors.get(type_code, '#909399')
|
||||
pie_chart_data.append({
|
||||
'type': type_code,
|
||||
'name': name,
|
||||
'value': item['count']
|
||||
'name': type_mapping.get(type_code, type_code),
|
||||
'value': item['total_students'] or 0,
|
||||
'itemStyle': { 'color': color }
|
||||
})
|
||||
|
||||
# If too many, maybe limit? For now, let's keep all active ones as user requested "specific names".
|
||||
# But if we have 0 students, maybe skip?
|
||||
# pie_chart_data = [d for d in pie_chart_data if d['value'] > 0]
|
||||
|
||||
# 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}
|
||||
{'name': '暂无数据', 'value': 0}
|
||||
]
|
||||
|
||||
# 3. Bar Chart: Popular dimension (organization/project)
|
||||
@@ -265,6 +286,35 @@ class DashboardStatsView(APIView):
|
||||
for item in coupon_status_counts
|
||||
]
|
||||
|
||||
# 6. Student City Distribution
|
||||
city_counts = Student.objects.values('city').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
def clean_city_name(name):
|
||||
if not name:
|
||||
return name
|
||||
|
||||
# Mapping from keyword to DataV standard name (Full Names required for DataV GeoJSON)
|
||||
mapping = {
|
||||
'北京': '北京市', '天津': '天津市', '上海': '上海市', '重庆': '重庆市',
|
||||
'河北': '河北省', '山西': '山西省', '辽宁': '辽宁省', '吉林': '吉林省', '黑龙江': '黑龙江省',
|
||||
'江苏': '江苏省', '浙江': '浙江省', '安徽': '安徽省', '福建': '福建省', '江西': '江西省', '山东': '山东省',
|
||||
'河南': '河南省', '湖北': '湖北省', '湖南': '湖南省', '广东': '广东省', '海南': '海南省',
|
||||
'四川': '四川省', '贵州': '贵州省', '云南': '云南省', '陕西': '陕西省', '甘肃': '甘肃省', '青海': '青海省', '台湾': '台湾省',
|
||||
'内蒙古': '内蒙古自治区', '广西': '广西壮族自治区', '西藏': '西藏自治区', '宁夏': '宁夏回族自治区', '新疆': '新疆维吾尔自治区',
|
||||
'香港': '香港特别行政区', '澳门': '澳门特别行政区'
|
||||
}
|
||||
|
||||
for key, full_name in mapping.items():
|
||||
if key in name:
|
||||
return full_name
|
||||
|
||||
return name
|
||||
|
||||
map_chart_data = [
|
||||
{'name': clean_city_name(item['city']), 'value': item['count']}
|
||||
for item in city_counts if item['city']
|
||||
]
|
||||
|
||||
return Response({
|
||||
'panel_data': {
|
||||
'students': student_count,
|
||||
@@ -276,7 +326,9 @@ class DashboardStatsView(APIView):
|
||||
'showcases_active': active_showcase_count
|
||||
},
|
||||
'pie_chart_data': pie_chart_data,
|
||||
'pie_chart_legend': pie_chart_legend,
|
||||
'coupon_pie_chart_data': coupon_pie_chart_data,
|
||||
'map_chart_data': map_chart_data,
|
||||
'bar_chart_data': {
|
||||
'title': bar_title,
|
||||
'names': bar_names,
|
||||
@@ -292,6 +344,24 @@ class CategoryViewSet(viewsets.ModelViewSet):
|
||||
pagination_class = None # Return all categories without pagination for the app
|
||||
search_fields = ['name']
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
# Instead of database categories, return Project.PROJECT_TYPE_CHOICES
|
||||
data = []
|
||||
# Define some default colors if needed, or mapped by type
|
||||
colors = {
|
||||
'training': 'bg-cyan-100 text-cyan-600',
|
||||
'competition': 'bg-purple-100 text-purple-600',
|
||||
'grading': 'bg-blue-100 text-blue-600'
|
||||
}
|
||||
|
||||
for code, name in Project.PROJECT_TYPE_CHOICES:
|
||||
data.append({
|
||||
'id': code,
|
||||
'name': name,
|
||||
'color': colors.get(code, 'bg-gray-100 text-gray-600')
|
||||
})
|
||||
return Response(data)
|
||||
|
||||
class TeacherViewSet(viewsets.ModelViewSet):
|
||||
queryset = Teacher.objects.all()
|
||||
serializer_class = TeacherSerializer
|
||||
@@ -539,7 +609,7 @@ class UserProfileView(APIView):
|
||||
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']
|
||||
fields = ['name', 'phone', 'age', 'company_name', 'position', 'address', 'city', 'wechat_nickname', 'avatar']
|
||||
for f in fields:
|
||||
if f in payload:
|
||||
setattr(student, f, payload.get(f))
|
||||
@@ -675,3 +745,202 @@ class UserHonorsView(APIView):
|
||||
honors = StudentHonor.objects.filter(student=student)
|
||||
serializer = StudentHonorSerializer(honors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class NotificationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
# 仅返回当前登录用户的通知
|
||||
# 需结合认证系统,假设 request.user.student 存在,或通过 openid 关联
|
||||
user = self.request.user
|
||||
|
||||
# Attempt to find the student associated with the request
|
||||
# Note: In our current mock auth setup, 'user' might be the Django Admin user.
|
||||
# But the frontend sends 'Authorization: Bearer mock_token_{id}'.
|
||||
# DRF's default authentication might not parse this mock token into request.user.
|
||||
# However, for consistency with the plan, let's implement the token parsing logic here
|
||||
# OR rely on a custom authentication class.
|
||||
|
||||
# Since other views use `get_student_from_token` helper, but ViewSets usually rely on Authentication classes.
|
||||
# Let's assume we can extract the student from the token here as well.
|
||||
|
||||
student = None
|
||||
auth = self.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
|
||||
|
||||
if student:
|
||||
return Notification.objects.filter(student=student)
|
||||
|
||||
# If accessed by admin user (Django user)
|
||||
if user.is_staff:
|
||||
return Notification.objects.all()
|
||||
|
||||
return Notification.objects.none()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def unread_count(self, request):
|
||||
count = self.get_queryset().filter(is_read=False).count()
|
||||
return Response({'count': count})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': 'marked as read'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def read_all(self, request):
|
||||
self.get_queryset().filter(is_read=False).update(is_read=True)
|
||||
return Response({'status': 'all marked as read'})
|
||||
|
||||
class NotificationBatchViewSet(viewsets.ModelViewSet):
|
||||
queryset = NotificationBatch.objects.all()
|
||||
serializer_class = NotificationBatchSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
# 1. Create Batch
|
||||
data = request.data
|
||||
send_mode = data.get('send_mode', 'custom')
|
||||
target_criteria = data.get('target_criteria', {})
|
||||
|
||||
# Ensure target_criteria is string for TextField
|
||||
if isinstance(target_criteria, dict):
|
||||
target_criteria_str = json.dumps(target_criteria)
|
||||
else:
|
||||
target_criteria_str = target_criteria
|
||||
try:
|
||||
target_criteria = json.loads(target_criteria)
|
||||
except:
|
||||
target_criteria = {}
|
||||
|
||||
# Validate basics
|
||||
# Use partial=True if some fields are missing but not required, though ModelSerializer usually handles it.
|
||||
# But we modify data to set target_criteria to string.
|
||||
# So we should create a mutable copy of data if it's immutable (QueryDict)
|
||||
if hasattr(data, '_mutable'):
|
||||
data._mutable = True
|
||||
|
||||
data['target_criteria'] = target_criteria_str
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
batch = serializer.save()
|
||||
|
||||
# 2. Find Recipients
|
||||
recipients = Student.objects.none()
|
||||
|
||||
if send_mode == 'custom':
|
||||
student_ids = target_criteria.get('student_ids', [])
|
||||
select_all = target_criteria.get('select_all', False)
|
||||
if select_all:
|
||||
recipients = Student.objects.all()
|
||||
elif student_ids:
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
elif send_mode == 'project':
|
||||
project_id = target_criteria.get('project_id')
|
||||
statuses = target_criteria.get('statuses', []) # ['enrolled', 'graduated']
|
||||
if project_id:
|
||||
query = {'enrolled_projects__id': project_id} # Filter students by enrolled project
|
||||
# But we want to filter by the status in that project enrollment?
|
||||
# StudentProject has status.
|
||||
# So we should query StudentProject first.
|
||||
|
||||
sp_query = {'project_id': project_id}
|
||||
if statuses:
|
||||
sp_query['status__in'] = statuses
|
||||
|
||||
student_ids = StudentProject.objects.filter(**sp_query).values_list('student_id', flat=True)
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
elif send_mode == 'coupon':
|
||||
coupon_id = target_criteria.get('coupon_id')
|
||||
coupon_status = target_criteria.get('status') # 'assigned', 'used', etc.
|
||||
if coupon_id:
|
||||
query = {'coupon_id': coupon_id}
|
||||
if coupon_status:
|
||||
query['status'] = coupon_status
|
||||
|
||||
student_ids = StudentCoupon.objects.filter(**query).values_list('student_id', flat=True)
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
# Deduplicate recipients if needed (though IDs set should handle it, but values_list returns list)
|
||||
recipients = recipients.distinct()
|
||||
|
||||
# 3. Create Notifications
|
||||
notification_list = []
|
||||
count = 0
|
||||
for student in recipients:
|
||||
notification_list.append(Notification(
|
||||
student=student,
|
||||
batch=batch,
|
||||
title=batch.title,
|
||||
content=batch.content,
|
||||
notification_type=batch.notification_type
|
||||
))
|
||||
count += 1
|
||||
|
||||
if notification_list:
|
||||
Notification.objects.bulk_create(notification_list)
|
||||
|
||||
# 4. Update Count
|
||||
batch.recipient_count = count
|
||||
batch.save()
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def recipients(self, request, pk=None):
|
||||
batch = self.get_object()
|
||||
notifications = Notification.objects.filter(batch=batch).select_related('student', 'student__teaching_center', 'student__responsible_teacher')
|
||||
|
||||
results = []
|
||||
# Parse criteria once
|
||||
try:
|
||||
criteria = json.loads(batch.target_criteria)
|
||||
except:
|
||||
criteria = {}
|
||||
|
||||
for n in notifications:
|
||||
student = n.student
|
||||
|
||||
project_info = ""
|
||||
status_info = student.get_status_display()
|
||||
|
||||
if batch.send_mode == 'project':
|
||||
project_id = criteria.get('project_id')
|
||||
# Optimally we should prefetch this, but for now loop is okay for admin view
|
||||
sp = StudentProject.objects.filter(student=student, project_id=project_id).first()
|
||||
if sp:
|
||||
project_info = sp.project.title
|
||||
status_info = sp.get_status_display()
|
||||
elif batch.send_mode == 'coupon':
|
||||
coupon_id = criteria.get('coupon_id')
|
||||
sc = StudentCoupon.objects.filter(student=student, coupon_id=coupon_id).first()
|
||||
if sc:
|
||||
project_info = sc.coupon.title
|
||||
status_info = sc.get_status_display()
|
||||
|
||||
results.append({
|
||||
'id': student.id,
|
||||
'student_name': student.name,
|
||||
'student_phone': student.phone,
|
||||
'teaching_center': student.teaching_center.name if student.teaching_center else (student.responsible_teacher.name if student.responsible_teacher else '-'),
|
||||
'project_info': project_info,
|
||||
'status_info': status_info,
|
||||
'is_read': n.is_read,
|
||||
'read_at': None
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
@@ -54,6 +54,14 @@ class FileSerializer(serializers.ModelSerializer):
|
||||
model = File
|
||||
fields = "__all__"
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
if instance.path and request:
|
||||
if not instance.path.startswith(('http://', 'https://')):
|
||||
ret['path'] = request.build_absolute_uri(instance.path)
|
||||
return ret
|
||||
|
||||
class DictTypeSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
数据字典类型序列化
|
||||
|
||||
@@ -26,6 +26,7 @@ from io import BytesIO
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
import sys
|
||||
import os
|
||||
import socket
|
||||
|
||||
from .filters import UserFilter
|
||||
from .mixins import CreateUpdateModelAMixin, OptimizationMixin
|
||||
@@ -422,7 +423,9 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
|
||||
|
||||
# Ensure forward slashes for URL
|
||||
file_name = instance.file.name.replace('\\', '/')
|
||||
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
|
||||
|
||||
# Save relative path
|
||||
instance.path = settings.MEDIA_URL + file_name
|
||||
|
||||
logger.info(f"File uploaded: {instance.path}")
|
||||
instance.save()
|
||||
@@ -438,3 +441,21 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
|
||||
file_obj.file.delete(save=False)
|
||||
file_obj.delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=['post'], detail=False)
|
||||
def replace_url(self, request):
|
||||
old_url = request.data.get('old_url')
|
||||
new_url = request.data.get('new_url')
|
||||
|
||||
if not old_url or not new_url:
|
||||
return Response({'error': 'Please provide both old_url and new_url'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
count = 0
|
||||
for file_obj in queryset:
|
||||
if file_obj.path and old_url in file_obj.path:
|
||||
file_obj.path = file_obj.path.replace(old_url, new_url)
|
||||
file_obj.save()
|
||||
count += 1
|
||||
|
||||
return Response({'message': f'Updated {count} files'}, status=status.HTTP_200_OK)
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 85 KiB |