Compare commits
21 Commits
9e2cf035c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ef10039c | ||
|
|
a9aefed4be | ||
|
|
08d645e572 | ||
|
|
ed352f5a71 | ||
|
|
8faaf38a08 | ||
|
|
c4cd2f97e9 | ||
|
|
8b0ada3cc0 | ||
|
|
81b5d3f4d8 | ||
|
|
467b8510a7 | ||
|
|
05e20501a9 | ||
|
|
381b7a8d60 | ||
|
|
c7af3b3e44 | ||
|
|
61c61a77a2 | ||
|
|
9292ef8d8d | ||
|
|
602ce92418 | ||
|
|
1384bb1d4a | ||
|
|
b8648c2861 | ||
|
|
c34b63b8da | ||
|
|
7b1961894d | ||
|
|
69733e8d4c | ||
|
|
0c5d90c12b |
3
.gitignore
vendored
@@ -6,3 +6,6 @@ __pycache__/
|
|||||||
.idea/
|
.idea/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.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
|
```javascript
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://192.168.5.81:8000', // 目标服务器地址
|
// 目标服务器地址,优先使用环境变量 PROXY_TARGET,默认为 localhost
|
||||||
|
target: process.env.PROXY_TARGET || 'http://127.0.0.1:8000',
|
||||||
changeOrigin: true
|
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 许可证。
|
||||||
16
STARTUP.md
@@ -52,7 +52,23 @@
|
|||||||
|
|
||||||
4. 启动开发服务器:
|
4. 启动开发服务器:
|
||||||
|
|
||||||
|
> **注意**: 如果你的 Node.js 版本 >= 17,可能会遇到 OpenSSL 相关的错误。请在启动前设置环境变量。
|
||||||
|
|
||||||
|
* **Windows (PowerShell)**:
|
||||||
```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
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ JWT认证,可使用simple_history实现审计功能,支持swagger
|
|||||||
|
|
||||||
安装node.js
|
安装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
|
||||||
本地跑时修改nginx.conf,可显示资源文件
|
本地跑时修改nginx.conf,可显示资源文件
|
||||||
|
|||||||
1
admin/client/public/china.json
Normal file
@@ -358,3 +358,67 @@ export function deleteShowcase(id) {
|
|||||||
method: 'delete'
|
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'
|
method: 'delete'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replaceUrl(data) {
|
||||||
|
return request({
|
||||||
|
url: '/file/replace_url/',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,35 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,13 +82,17 @@ export default {
|
|||||||
...mapGetters([
|
...mapGetters([
|
||||||
'sidebar',
|
'sidebar',
|
||||||
'avatar',
|
'avatar',
|
||||||
'name'
|
'name',
|
||||||
|
'theme'
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleSideBar() {
|
toggleSideBar() {
|
||||||
this.$store.dispatch('app/toggleSideBar')
|
this.$store.dispatch('app/toggleSideBar')
|
||||||
},
|
},
|
||||||
|
setTheme(theme) {
|
||||||
|
this.$store.dispatch('settings/setTheme', theme)
|
||||||
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
await this.$store.dispatch('user/logout')
|
await this.$store.dispatch('user/logout')
|
||||||
this.$router.push(`/login?redirect=${this.$route.fullPath}`)
|
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>
|
</style>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
background: #2b333e;
|
background: var(--menuBg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export default {
|
|||||||
& .sidebar-title {
|
& .sidebar-title {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #fff;
|
color: var(--menuText);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="{'has-logo':showLogo}">
|
<div :class="{'has-logo':showLogo}" :style="cssVars">
|
||||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -22,14 +22,14 @@
|
|||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import Logo from './Logo'
|
import Logo from './Logo'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
import variables from '@/styles/variables.scss'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { SidebarItem, Logo },
|
components: { SidebarItem, Logo },
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters([
|
...mapGetters([
|
||||||
'permission_routes',
|
'permission_routes',
|
||||||
'sidebar'
|
'sidebar',
|
||||||
|
'theme'
|
||||||
]),
|
]),
|
||||||
activeMenu() {
|
activeMenu() {
|
||||||
const route = this.$route
|
const route = this.$route
|
||||||
@@ -43,8 +43,47 @@ export default {
|
|||||||
showLogo() {
|
showLogo() {
|
||||||
return this.$store.state.settings.sidebarLogo
|
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() {
|
variables() {
|
||||||
return variables
|
const vars = this.cssVars
|
||||||
|
return {
|
||||||
|
menuBg: vars['--menuBg'],
|
||||||
|
menuText: vars['--menuText'],
|
||||||
|
menuActiveText: vars['--menuActiveText']
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isCollapse() {
|
isCollapse() {
|
||||||
return !this.sidebar.opened
|
return !this.sidebar.opened
|
||||||
|
|||||||
@@ -119,23 +119,12 @@ export const asyncRoutes = [
|
|||||||
meta: { title: '教学中心', icon: 'user' }
|
meta: { title: '教学中心', icon: 'user' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'coupons',
|
path: 'users',
|
||||||
name: 'Coupons',
|
name: 'CrmUsers',
|
||||||
component: () => import('@/views/crm/coupon'),
|
component: () => import('@/views/crm/index'),
|
||||||
meta: { title: '优惠券管理', icon: 'money' }
|
redirect: '/crm/users/students',
|
||||||
},
|
meta: { title: '用户管理', icon: 'peoples' },
|
||||||
{
|
children: [
|
||||||
path: 'issued-coupons',
|
|
||||||
name: 'IssuedCoupons',
|
|
||||||
component: () => import('@/views/crm/issued_coupon'),
|
|
||||||
meta: { title: '已发优惠券', icon: 'list' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'banners',
|
|
||||||
name: 'Banners',
|
|
||||||
component: () => import('@/views/crm/banner'),
|
|
||||||
meta: { title: '轮播图管理', icon: 'drag' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'students',
|
path: 'students',
|
||||||
name: 'Students',
|
name: 'Students',
|
||||||
@@ -147,12 +136,74 @@ export const asyncRoutes = [
|
|||||||
name: 'Honors',
|
name: 'Honors',
|
||||||
component: () => import('@/views/crm/honor'),
|
component: () => import('@/views/crm/honor'),
|
||||||
meta: { title: '学员荣誉管理', icon: 'medal' }
|
meta: { title: '学员荣誉管理', icon: 'medal' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
name: 'Banners',
|
||||||
|
component: () => import('@/views/crm/banner'),
|
||||||
|
meta: { title: '轮播图管理', icon: 'drag' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'showcases',
|
path: 'showcases',
|
||||||
name: 'Showcases',
|
name: 'Showcases',
|
||||||
component: () => import('@/views/crm/showcase'),
|
component: () => import('@/views/crm/showcase'),
|
||||||
meta: { title: '精彩视频', icon: 'video' }
|
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,
|
avatar: state => state.user.avatar,
|
||||||
name: state => state.user.name,
|
name: state => state.user.name,
|
||||||
perms: state => state.user.perms,
|
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
|
export default getters
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const { showSettings, fixedHeader, sidebarLogo } = defaultSettings
|
|||||||
const state = {
|
const state = {
|
||||||
showSettings: showSettings,
|
showSettings: showSettings,
|
||||||
fixedHeader: fixedHeader,
|
fixedHeader: fixedHeader,
|
||||||
sidebarLogo: sidebarLogo
|
sidebarLogo: sidebarLogo,
|
||||||
|
theme: 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutations = {
|
const mutations = {
|
||||||
@@ -13,12 +14,18 @@ const mutations = {
|
|||||||
if (state.hasOwnProperty(key)) {
|
if (state.hasOwnProperty(key)) {
|
||||||
state[key] = value
|
state[key] = value
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
SET_THEME: (state, theme) => {
|
||||||
|
state.theme = theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
changeSetting({ commit }, data) {
|
changeSetting({ commit }, data) {
|
||||||
commit('CHANGE_SETTING', data)
|
commit('CHANGE_SETTING', data)
|
||||||
|
},
|
||||||
|
setTheme({ commit }, theme) {
|
||||||
|
commit('SET_THEME', theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
transition: width 0.28s;
|
transition: width 0.28s;
|
||||||
width: $sideBarWidth !important;
|
width: $sideBarWidth !important;
|
||||||
background-color: $menuBg;
|
background-color: var(--menuBg);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
@@ -67,21 +67,21 @@
|
|||||||
.submenu-title-noDropdown,
|
.submenu-title-noDropdown,
|
||||||
.el-submenu__title {
|
.el-submenu__title {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $menuHover !important;
|
background-color: var(--menuHover) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active>.el-submenu__title {
|
.is-active>.el-submenu__title {
|
||||||
color: $subMenuActiveText !important;
|
color: var(--subMenuActiveText) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .nest-menu .el-submenu>.el-submenu__title,
|
& .nest-menu .el-submenu>.el-submenu__title,
|
||||||
& .el-submenu .el-menu-item {
|
& .el-submenu .el-menu-item {
|
||||||
min-width: $sideBarWidth !important;
|
min-width: $sideBarWidth !important;
|
||||||
background-color: $subMenuBg !important;
|
background-color: var(--subMenuBg) !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $subMenuHover !important;
|
background-color: var(--subMenuHover) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,45 @@ service.interceptors.response.use(
|
|||||||
*/
|
*/
|
||||||
response => {
|
response => {
|
||||||
const res = response.data
|
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){
|
if(res.code>=200 && res.code<400){
|
||||||
return res
|
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>
|
<template slot-scope="{row}"><span>{{ row.teacher_name }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="封面" width="80px" align="center">
|
<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>
|
||||||
<el-table-column label="学习人数" width="80px" align="center">
|
<el-table-column label="学习人数" width="80px" align="center">
|
||||||
<template slot-scope="{row}"><span>{{ row.students }}</span></template>
|
<template slot-scope="{row}"><span>{{ row.students }}</span></template>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:on-success="handleAvatarSuccess"
|
:on-success="handleAvatarSuccess"
|
||||||
:headers="upHeaders">
|
: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>
|
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -362,6 +362,16 @@ export default {
|
|||||||
detail: ''
|
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() {
|
handleCreate() {
|
||||||
this.resetTemp()
|
this.resetTemp()
|
||||||
this.dialogStatus = 'create'
|
this.dialogStatus = 'create'
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
<span>{{ row.age }}</span>
|
<span>{{ row.age }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="已报项目" align="center" min-width="200">
|
||||||
<template slot-scope="{row}">
|
<template slot-scope="{row}">
|
||||||
<div v-if="row.enrolled_projects && row.enrolled_projects.length > 0" style="text-align: left;">
|
<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-form-item label="年龄" prop="age">
|
||||||
<el-input v-model.number="temp.age" />
|
<el-input v-model.number="temp.age" />
|
||||||
</el-form-item>
|
</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-form-item label="地址" prop="address">
|
||||||
<el-input v-model="temp.address" />
|
<el-input v-model="temp.address" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -219,6 +227,7 @@ export default {
|
|||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
age: undefined,
|
age: undefined,
|
||||||
|
city: '',
|
||||||
address: '',
|
address: '',
|
||||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||||
openid: '',
|
openid: '',
|
||||||
@@ -276,6 +285,7 @@ export default {
|
|||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
age: undefined,
|
age: undefined,
|
||||||
|
city: '',
|
||||||
address: '',
|
address: '',
|
||||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||||
openid: '',
|
openid: '',
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ export default {
|
|||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '300px'
|
default: '350px'
|
||||||
},
|
},
|
||||||
chartData: {
|
chartData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ names: [], counts: [], title: '热门机构' })
|
default: () => ({ names: [], counts: [], title: '机构学员' })
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: 'dark'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -32,6 +36,17 @@ export default {
|
|||||||
chart: null
|
chart: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
theme() {
|
||||||
|
this.initChart()
|
||||||
|
},
|
||||||
|
chartData: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.initChart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.initChart()
|
this.initChart()
|
||||||
@@ -46,13 +61,28 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initChart() {
|
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')
|
this.chart = echarts.init(this.$el, 'macarons')
|
||||||
|
|
||||||
const names = this.chartData.names || []
|
const names = this.chartData.names || []
|
||||||
const counts = this.chartData.counts || []
|
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({
|
this.chart.setOption({
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
textStyle: {
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
left: 'center'
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { // 坐标轴指示器,坐标轴触发有效
|
axisPointer: { // 坐标轴指示器,坐标轴触发有效
|
||||||
@@ -60,7 +90,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
top: 10,
|
top: 40,
|
||||||
left: '2%',
|
left: '2%',
|
||||||
right: '2%',
|
right: '2%',
|
||||||
bottom: '3%',
|
bottom: '3%',
|
||||||
@@ -71,12 +101,34 @@ export default {
|
|||||||
data: names,
|
data: names,
|
||||||
axisTick: {
|
axisTick: {
|
||||||
alignWithLabel: true
|
alignWithLabel: true
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: axisLineColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: axisLineColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
yAxis: [{
|
yAxis: [{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
|
minInterval: 1, // Ensure integer ticks
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: false
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: axisLineColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: axisLineColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: splitLineColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
series: [{
|
series: [{
|
||||||
@@ -85,7 +137,34 @@ export default {
|
|||||||
stack: 'vistors',
|
stack: 'vistors',
|
||||||
barWidth: '60%',
|
barWidth: '60%',
|
||||||
data: counts,
|
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: {
|
chartData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: 'dark'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -37,6 +41,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
theme() {
|
||||||
|
this.setOptions(this.chartData)
|
||||||
|
},
|
||||||
chartData: {
|
chartData: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler(val) {
|
handler(val) {
|
||||||
@@ -58,16 +65,31 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initChart() {
|
initChart() {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose()
|
||||||
|
}
|
||||||
this.chart = echarts.init(this.$el, 'macarons')
|
this.chart = echarts.init(this.$el, 'macarons')
|
||||||
this.setOptions(this.chartData)
|
this.setOptions(this.chartData)
|
||||||
},
|
},
|
||||||
setOptions({ expectedData, actualData, xAxis } = {}) {
|
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({
|
this.chart.setOption({
|
||||||
xAxis: {
|
xAxis: {
|
||||||
data: xAxis && xAxis.length ? xAxis : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
data: xAxis && xAxis.length ? xAxis : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: false
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -87,10 +109,26 @@ export default {
|
|||||||
yAxis: {
|
yAxis: {
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: false
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: splitLineColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['上周', '本周']
|
data: ['上周', '本周'],
|
||||||
|
textStyle: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
name: '上周', itemStyle: {
|
name: '上周', itemStyle: {
|
||||||
@@ -98,7 +136,9 @@ export default {
|
|||||||
color: '#FF005A',
|
color: '#FF005A',
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: '#FF005A',
|
color: '#FF005A',
|
||||||
width: 2
|
width: 3,
|
||||||
|
shadowColor: 'rgba(255, 0, 90, 0.3)',
|
||||||
|
shadowBlur: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -114,13 +154,27 @@ export default {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
normal: {
|
normal: {
|
||||||
color: '#3888fa',
|
color: '#00f2fe',
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: '#3888fa',
|
color: '#00f2fe',
|
||||||
width: 2
|
width: 3,
|
||||||
|
shadowColor: 'rgba(0, 242, 254, 0.3)',
|
||||||
|
shadowBlur: 10
|
||||||
},
|
},
|
||||||
areaStyle: {
|
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>
|
<template>
|
||||||
<div>
|
<div :class="'theme-' + theme">
|
||||||
<el-row :gutter="40" class="panel-group">
|
<el-row :gutter="40" class="panel-group">
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('students')">
|
<div class="card-panel card-panel-blue" @click="handleSetLineChartData('students')">
|
||||||
<div class="card-panel-icon-wrapper icon-people">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="peoples" class-name="card-panel-icon" />
|
<svg-icon icon-class="peoples" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('organizations')">
|
<div class="card-panel card-panel-purple" @click="handleSetLineChartData('organizations')">
|
||||||
<div class="card-panel-icon-wrapper icon-message">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="education" class-name="card-panel-icon" />
|
<svg-icon icon-class="education" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
<div class="card-panel card-panel-orange" @click="handleSetLineChartData('projects')">
|
||||||
<div class="card-panel-icon-wrapper icon-money">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="component" class-name="card-panel-icon" />
|
<svg-icon icon-class="component" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
<div class="card-panel card-panel-green" @click="handleSetLineChartData('coupons')">
|
||||||
<div class="card-panel-icon-wrapper icon-shopping">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="money" class-name="card-panel-icon" />
|
<svg-icon icon-class="money" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="40" class="panel-group">
|
<el-row :gutter="40" class="panel-group">
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
<div class="card-panel card-panel-red" @click="handleSetLineChartData('projects')">
|
||||||
<div class="card-panel-icon-wrapper icon-money">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="list" class-name="card-panel-icon" />
|
<svg-icon icon-class="list" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
<div class="card-panel card-panel-yellow" @click="handleSetLineChartData('coupons')">
|
||||||
<div class="card-panel-icon-wrapper icon-banner">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="tab" class-name="card-panel-icon" />
|
<svg-icon icon-class="tab" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
<div class="card-panel card-panel-pink" @click="handleSetLineChartData('coupons')">
|
||||||
<div class="card-panel-icon-wrapper icon-video">
|
<div class="card-panel-icon-wrapper">
|
||||||
<svg-icon icon-class="star" class-name="card-panel-icon" />
|
<svg-icon icon-class="star" class-name="card-panel-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-panel-description">
|
<div class="card-panel-description">
|
||||||
@@ -104,11 +104,17 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CountTo from 'vue-count-to'
|
import CountTo from 'vue-count-to'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
CountTo
|
CountTo
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters([
|
||||||
|
'theme'
|
||||||
|
])
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
panelData: {
|
panelData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -148,96 +154,28 @@ export default {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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;
|
border-radius: 12px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 24px;
|
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 {
|
.card-panel-icon-wrapper {
|
||||||
float: none;
|
float: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
transition: all 0.38s ease-out;
|
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 {
|
.card-panel-icon {
|
||||||
float: left;
|
float: left;
|
||||||
font-size: 48px;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-panel-description {
|
.card-panel-description {
|
||||||
@@ -250,18 +188,161 @@ export default {
|
|||||||
|
|
||||||
.card-panel-text {
|
.card-panel-text {
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-panel-num {
|
.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) {
|
@media (max-width:550px) {
|
||||||
.card-panel-description {
|
.card-panel-description {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -272,6 +353,8 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -20,11 +20,19 @@ export default {
|
|||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '300px'
|
default: '350px'
|
||||||
},
|
},
|
||||||
chartData: {
|
chartData: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
legendData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: 'dark'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -32,6 +40,17 @@ export default {
|
|||||||
chart: null
|
chart: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
theme() {
|
||||||
|
this.initChart()
|
||||||
|
},
|
||||||
|
chartData: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.initChart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.initChart()
|
this.initChart()
|
||||||
@@ -46,11 +65,22 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initChart() {
|
initChart() {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.dispose()
|
||||||
|
}
|
||||||
this.chart = echarts.init(this.$el, 'macarons')
|
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({
|
this.chart.setOption({
|
||||||
|
title: {
|
||||||
|
text: '项目人数',
|
||||||
|
left: 'center',
|
||||||
|
textStyle: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: '{a} <br/>{b} : {c} ({d}%)'
|
formatter: '{a} <br/>{b} : {c} ({d}%)'
|
||||||
@@ -58,7 +88,10 @@ export default {
|
|||||||
legend: {
|
legend: {
|
||||||
left: 'center',
|
left: 'center',
|
||||||
bottom: '10',
|
bottom: '10',
|
||||||
data: legendData
|
data: this.legendData && this.legendData.map(item => item.name),
|
||||||
|
textStyle: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -66,15 +99,27 @@ export default {
|
|||||||
type: 'pie',
|
type: 'pie',
|
||||||
roseType: 'radius',
|
roseType: 'radius',
|
||||||
radius: [15, 95],
|
radius: [15, 95],
|
||||||
center: ['50%', '38%'],
|
center: ['50%', '45%'],
|
||||||
data: this.chartData || [],
|
data: this.chartData || [],
|
||||||
animationEasing: 'cubicInOut',
|
animationEasing: 'cubicInOut',
|
||||||
animationDuration: 2600,
|
animationDuration: 2600,
|
||||||
label: {
|
label: {
|
||||||
show: false
|
show: true,
|
||||||
|
formatter: '{b}',
|
||||||
|
color: textColor
|
||||||
},
|
},
|
||||||
labelLine: {
|
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>
|
</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>
|
<template>
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container" :class="'theme-' + theme">
|
||||||
<div v-if="loading" class="chart-wrapper">加载中...</div>
|
<div v-if="loading" class="chart-wrapper">加载中...</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<panel-group
|
<panel-group
|
||||||
@@ -7,21 +7,21 @@
|
|||||||
@handleSetLineChartData="handleSetLineChartData"
|
@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);">
|
<el-row :gutter="32">
|
||||||
<line-chart :chart-data="lineChartData" />
|
<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>
|
||||||
|
|
||||||
<el-row :gutter="32">
|
<el-row class="map-chart-wrapper">
|
||||||
<el-col :xs="24" :sm="24" :lg="8">
|
<map-chart :chart-data="mapChartData" :theme="theme" />
|
||||||
<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>
|
</el-row>
|
||||||
<div v-if="error" class="chart-wrapper">{{ error }}</div>
|
<div v-if="error" class="chart-wrapper">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,8 +29,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
import PanelGroup from './components/PanelGroup'
|
import PanelGroup from './components/PanelGroup'
|
||||||
import LineChart from './components/LineChart'
|
import MapChart from './components/MapChart'
|
||||||
import PieChart from './components/PieChart'
|
import PieChart from './components/PieChart'
|
||||||
import BarChart from './components/BarChart'
|
import BarChart from './components/BarChart'
|
||||||
import { getDashboardStats } from '@/api/dashboard'
|
import { getDashboardStats } from '@/api/dashboard'
|
||||||
@@ -39,14 +40,20 @@ export default {
|
|||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
components: {
|
components: {
|
||||||
PanelGroup,
|
PanelGroup,
|
||||||
LineChart,
|
MapChart,
|
||||||
PieChart,
|
PieChart,
|
||||||
BarChart
|
BarChart
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters([
|
||||||
|
'theme'
|
||||||
|
])
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: '',
|
error: '',
|
||||||
|
mapChartData: [],
|
||||||
lineChartData: {
|
lineChartData: {
|
||||||
expectedData: [],
|
expectedData: [],
|
||||||
actualData: [],
|
actualData: [],
|
||||||
@@ -63,8 +70,9 @@ export default {
|
|||||||
showcases_active: 0
|
showcases_active: 0
|
||||||
},
|
},
|
||||||
pieChartData: [],
|
pieChartData: [],
|
||||||
|
pieChartLegend: [],
|
||||||
barChartData: {
|
barChartData: {
|
||||||
title: '热门机构',
|
title: '机构学员',
|
||||||
names: [],
|
names: [],
|
||||||
counts: []
|
counts: []
|
||||||
}
|
}
|
||||||
@@ -83,7 +91,9 @@ export default {
|
|||||||
this.allLineChartData = data.line_chart_data
|
this.allLineChartData = data.line_chart_data
|
||||||
// Default to organizations line chart
|
// Default to organizations line chart
|
||||||
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
|
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
|
||||||
|
this.mapChartData = data.map_chart_data
|
||||||
this.pieChartData = data.pie_chart_data
|
this.pieChartData = data.pie_chart_data
|
||||||
|
this.pieChartLegend = data.pie_chart_legend
|
||||||
this.barChartData = data.bar_chart_data
|
this.barChartData = data.bar_chart_data
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -104,19 +114,80 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
background-color: rgb(240, 242, 245);
|
min-height: 100vh;
|
||||||
position: relative;
|
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 {
|
.chart-wrapper {
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
padding: 16px 16px 0;
|
padding: 16px 16px 0;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
border-radius: 12px;
|
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;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&: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"
|
@click="handleClear"
|
||||||
size="small"
|
size="small"
|
||||||
>一键清空</el-button>
|
>一键清空</el-button>
|
||||||
|
<el-button
|
||||||
|
class="filter-item"
|
||||||
|
type="warning"
|
||||||
|
icon="el-icon-edit"
|
||||||
|
@click="handleReplaceUrl"
|
||||||
|
size="small"
|
||||||
|
>一键修改IP/URL</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="listLoading"
|
v-loading="listLoading"
|
||||||
@@ -86,6 +93,21 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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
|
<pagination
|
||||||
v-show="fileList.count>0"
|
v-show="fileList.count>0"
|
||||||
:total="fileList.count"
|
:total="fileList.count"
|
||||||
@@ -96,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { getFileList, deleteFile, clearFiles } from "@/api/file"
|
import { getFileList, deleteFile, clearFiles, replaceUrl } from "@/api/file"
|
||||||
import Pagination from "@/components/Pagination"
|
import Pagination from "@/components/Pagination"
|
||||||
export default {
|
export default {
|
||||||
components: { Pagination },
|
components: { Pagination },
|
||||||
@@ -108,6 +130,11 @@ export default {
|
|||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20
|
page_size: 20
|
||||||
},
|
},
|
||||||
|
dialogFormVisible: false,
|
||||||
|
temp: {
|
||||||
|
old_url: '',
|
||||||
|
new_url: ''
|
||||||
|
},
|
||||||
enabledOptions: [
|
enabledOptions: [
|
||||||
{ key: "文档", display_name: "文档" },
|
{ key: "文档", display_name: "文档" },
|
||||||
{ key: "图片", display_name: "图片" },
|
{ key: "图片", display_name: "图片" },
|
||||||
@@ -175,6 +202,35 @@ export default {
|
|||||||
this.getList()
|
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: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
// target: 'http://localhost:8000',
|
// 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
|
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 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)
|
@admin.register(Category)
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
@@ -38,16 +53,20 @@ class BannerAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Student)
|
@admin.register(Student)
|
||||||
class StudentAdmin(admin.ModelAdmin):
|
class StudentAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'phone', 'wechat_nickname', 'openid', 'teaching_center', 'company_name', 'status', 'learning_count', 'created_at')
|
list_display = ('name', 'phone', 'responsible_teacher', 'is_active', 'status', 'teaching_center', 'created_at')
|
||||||
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name')
|
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name', 'responsible_teacher__name')
|
||||||
list_filter = ('teaching_center', 'status')
|
list_filter = ('teaching_center', 'status', 'is_active', 'responsible_teacher')
|
||||||
inlines = []
|
inlines = []
|
||||||
|
|
||||||
|
class StudentProjectInline(admin.TabularInline):
|
||||||
|
model = StudentProject
|
||||||
|
extra = 1
|
||||||
|
|
||||||
class StudentCouponInline(admin.TabularInline):
|
class StudentCouponInline(admin.TabularInline):
|
||||||
model = StudentCoupon
|
model = StudentCoupon
|
||||||
extra = 1
|
extra = 1
|
||||||
|
|
||||||
StudentAdmin.inlines = [StudentCouponInline]
|
StudentAdmin.inlines = [StudentProjectInline, StudentCouponInline]
|
||||||
|
|
||||||
@admin.register(StudentCoupon)
|
@admin.register(StudentCoupon)
|
||||||
class StudentCouponAdmin(admin.ModelAdmin):
|
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")
|
# 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")
|
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)
|
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="")
|
detail = models.TextField(verbose_name="项目详情", blank=True, default="")
|
||||||
students = models.IntegerField(default=0, verbose_name="学习人数")
|
students = models.IntegerField(default=0, verbose_name="学习人数")
|
||||||
address = models.CharField(max_length=200, verbose_name="地址", default="", blank=True)
|
address = models.CharField(max_length=200, verbose_name="地址", default="", blank=True)
|
||||||
@@ -106,7 +106,7 @@ class Coupon(models.Model):
|
|||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class Banner(models.Model):
|
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="关联项目")
|
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="跳转链接")
|
link = models.CharField(max_length=200, blank=True, null=True, verbose_name="跳转链接")
|
||||||
sort_order = models.IntegerField(default=0, verbose_name="排序")
|
sort_order = models.IntegerField(default=0, verbose_name="排序")
|
||||||
@@ -130,10 +130,14 @@ class Student(models.Model):
|
|||||||
# 用户需求是“微信唯一标识”,这通常指 OpenID。
|
# 用户需求是“微信唯一标识”,这通常指 OpenID。
|
||||||
openid = models.CharField(max_length=100, verbose_name="微信唯一标识", null=True, blank=True, unique=True)
|
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)
|
# 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)
|
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字段,对应微信头像
|
||||||
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="关联教学中心")
|
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):
|
class StudentHonor(models.Model):
|
||||||
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="honors", verbose_name="学员")
|
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="honors", verbose_name="学员")
|
||||||
title = models.CharField(max_length=100, 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="获得日期")
|
date = models.DateField(verbose_name="获得日期")
|
||||||
description = models.TextField(verbose_name="荣誉描述", blank=True, default="")
|
description = models.TextField(verbose_name="荣誉描述", blank=True, default="")
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
@@ -266,8 +270,8 @@ class StudentHonor(models.Model):
|
|||||||
|
|
||||||
class StudentShowcase(models.Model):
|
class StudentShowcase(models.Model):
|
||||||
title = models.CharField(max_length=100, verbose_name="标题")
|
title = models.CharField(max_length=100, verbose_name="标题")
|
||||||
cover_image = models.URLField(verbose_name="封面图片URL")
|
cover_image = models.CharField(max_length=500, verbose_name="封面图片URL")
|
||||||
video_url = models.URLField(verbose_name="视频链接URL", blank=True, null=True)
|
video_url = models.CharField(max_length=500, verbose_name="视频链接URL", blank=True, null=True)
|
||||||
description = models.TextField(verbose_name="描述", blank=True, default="")
|
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")
|
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="排序")
|
sort_order = models.IntegerField(default=0, verbose_name="排序")
|
||||||
@@ -303,3 +307,58 @@ def activate_coupons_on_phone_bind(sender, instance, created, **kwargs):
|
|||||||
coupon=coupon,
|
coupon=coupon,
|
||||||
status='assigned'
|
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 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 CategorySerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -19,13 +38,24 @@ class TeachingCenterSerializer(serializers.ModelSerializer):
|
|||||||
class ProjectSerializer(serializers.ModelSerializer):
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
# category_name = serializers.CharField(source='category.name', read_only=True)
|
# category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
# category_color = serializers.CharField(source='category.color', 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()
|
teacher_name = serializers.SerializerMethodField()
|
||||||
project_type_display = serializers.CharField(source='get_project_type_display', read_only=True)
|
project_type_display = serializers.CharField(source='get_project_type_display', read_only=True)
|
||||||
|
image = AbsoluteURLField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = '__all__'
|
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):
|
def get_teacher_name(self, obj):
|
||||||
if obj.teacher:
|
if obj.teacher:
|
||||||
return obj.teacher.name
|
return obj.teacher.name
|
||||||
@@ -62,21 +92,24 @@ class StudentCouponSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class BannerSerializer(serializers.ModelSerializer):
|
class BannerSerializer(serializers.ModelSerializer):
|
||||||
project_title = serializers.CharField(source='project.title', read_only=True)
|
project_title = serializers.CharField(source='project.title', read_only=True)
|
||||||
|
image = AbsoluteURLField()
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Banner
|
model = Banner
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
class StudentSerializer(serializers.ModelSerializer):
|
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)
|
teaching_center_name = serializers.CharField(source='teaching_center.name', read_only=True)
|
||||||
stats = serializers.SerializerMethodField()
|
stats = serializers.SerializerMethodField()
|
||||||
enrolled_projects = serializers.SerializerMethodField()
|
enrolled_projects = serializers.SerializerMethodField()
|
||||||
coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons')
|
coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons')
|
||||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
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:
|
class Meta:
|
||||||
model = Student
|
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']
|
read_only_fields = ['stats', 'enrolled_projects', 'coupons', 'teaching_center_name', 'teacher_name', 'status_display']
|
||||||
|
|
||||||
def get_stats(self, obj):
|
def get_stats(self, obj):
|
||||||
@@ -101,7 +134,7 @@ class StudentProjectSerializer(serializers.ModelSerializer):
|
|||||||
student_name = serializers.CharField(source='student.name', read_only=True)
|
student_name = serializers.CharField(source='student.name', read_only=True)
|
||||||
student_phone = serializers.CharField(source='student.phone', read_only=True)
|
student_phone = serializers.CharField(source='student.phone', read_only=True)
|
||||||
project_title = serializers.CharField(source='project.title', 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 = serializers.CharField(source='project.project_type', read_only=True)
|
||||||
project_type_display = serializers.CharField(source='project.get_project_type_display', 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):
|
class StudentHonorSerializer(serializers.ModelSerializer):
|
||||||
student_name = serializers.CharField(source='student.name', read_only=True)
|
student_name = serializers.CharField(source='student.name', read_only=True)
|
||||||
student_phone = serializers.CharField(source='student.phone', 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_openid = serializers.CharField(source='student.openid', read_only=True)
|
||||||
student_teaching_center = serializers.CharField(source='student.teacher.name', 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)
|
student_company_name = serializers.CharField(source='student.company_name', read_only=True)
|
||||||
|
image = AbsoluteURLField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StudentHonor
|
model = StudentHonor
|
||||||
@@ -123,7 +157,23 @@ class StudentHonorSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class StudentShowcaseSerializer(serializers.ModelSerializer):
|
class StudentShowcaseSerializer(serializers.ModelSerializer):
|
||||||
student_name = serializers.CharField(source='student.name', read_only=True)
|
student_name = serializers.CharField(source='student.name', read_only=True)
|
||||||
|
cover_image = AbsoluteURLField()
|
||||||
|
video_url = AbsoluteURLField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StudentShowcase
|
model = StudentShowcase
|
||||||
fields = '__all__'
|
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 django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
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 = DefaultRouter()
|
||||||
router.register(r'categories', CategoryViewSet)
|
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-projects', StudentProjectViewSet)
|
||||||
router.register(r'student-honors', StudentHonorViewSet)
|
router.register(r'student-honors', StudentHonorViewSet)
|
||||||
router.register(r'student-showcases', StudentShowcaseViewSet)
|
router.register(r'student-showcases', StudentShowcaseViewSet)
|
||||||
|
router.register(r'notifications', NotificationViewSet, basename='notification')
|
||||||
|
router.register(r'notification-batches', NotificationBatchViewSet, basename='notification-batch')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated
|
||||||
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
|
||||||
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer
|
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer, NotificationSerializer, NotificationBatchSerializer
|
||||||
import requests
|
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 django.utils import timezone
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
@@ -181,28 +182,48 @@ class DashboardStatsView(APIView):
|
|||||||
active_showcase_count = StudentShowcase.objects.filter(is_active=True).count()
|
active_showcase_count = StudentShowcase.objects.filter(is_active=True).count()
|
||||||
active_project_count = Project.objects.filter(is_active=True).count()
|
active_project_count = Project.objects.filter(is_active=True).count()
|
||||||
|
|
||||||
# 2. Pie Chart: Project Types Distribution
|
# 2. Pie Chart: Project Category Proportion
|
||||||
# Group by project_type
|
# Aggregate student counts by project type
|
||||||
project_types_data = Project.objects.values('project_type').annotate(count=Count('id'))
|
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)
|
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 = []
|
pie_chart_data = []
|
||||||
for item in project_types_data:
|
for item in projects_data:
|
||||||
type_code = item['project_type']
|
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({
|
pie_chart_data.append({
|
||||||
'type': type_code,
|
'type': type_code,
|
||||||
'name': name,
|
'name': type_mapping.get(type_code, type_code),
|
||||||
'value': item['count']
|
'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 empty, provide some defaults to avoid empty chart
|
||||||
if not pie_chart_data:
|
if not pie_chart_data:
|
||||||
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)
|
# 3. Bar Chart: Popular dimension (organization/project)
|
||||||
@@ -265,6 +286,35 @@ class DashboardStatsView(APIView):
|
|||||||
for item in coupon_status_counts
|
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({
|
return Response({
|
||||||
'panel_data': {
|
'panel_data': {
|
||||||
'students': student_count,
|
'students': student_count,
|
||||||
@@ -276,7 +326,9 @@ class DashboardStatsView(APIView):
|
|||||||
'showcases_active': active_showcase_count
|
'showcases_active': active_showcase_count
|
||||||
},
|
},
|
||||||
'pie_chart_data': pie_chart_data,
|
'pie_chart_data': pie_chart_data,
|
||||||
|
'pie_chart_legend': pie_chart_legend,
|
||||||
'coupon_pie_chart_data': coupon_pie_chart_data,
|
'coupon_pie_chart_data': coupon_pie_chart_data,
|
||||||
|
'map_chart_data': map_chart_data,
|
||||||
'bar_chart_data': {
|
'bar_chart_data': {
|
||||||
'title': bar_title,
|
'title': bar_title,
|
||||||
'names': bar_names,
|
'names': bar_names,
|
||||||
@@ -292,6 +344,24 @@ class CategoryViewSet(viewsets.ModelViewSet):
|
|||||||
pagination_class = None # Return all categories without pagination for the app
|
pagination_class = None # Return all categories without pagination for the app
|
||||||
search_fields = ['name']
|
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):
|
class TeacherViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Teacher.objects.all()
|
queryset = Teacher.objects.all()
|
||||||
serializer_class = TeacherSerializer
|
serializer_class = TeacherSerializer
|
||||||
@@ -539,7 +609,7 @@ class UserProfileView(APIView):
|
|||||||
return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
payload = request.data or {}
|
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:
|
for f in fields:
|
||||||
if f in payload:
|
if f in payload:
|
||||||
setattr(student, f, payload.get(f))
|
setattr(student, f, payload.get(f))
|
||||||
@@ -675,3 +745,202 @@ class UserHonorsView(APIView):
|
|||||||
honors = StudentHonor.objects.filter(student=student)
|
honors = StudentHonor.objects.filter(student=student)
|
||||||
serializer = StudentHonorSerializer(honors, many=True)
|
serializer = StudentHonorSerializer(honors, many=True)
|
||||||
return Response(serializer.data)
|
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
|
model = File
|
||||||
fields = "__all__"
|
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):
|
class DictTypeSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
数据字典类型序列化
|
数据字典类型序列化
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from io import BytesIO
|
|||||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
from .filters import UserFilter
|
from .filters import UserFilter
|
||||||
from .mixins import CreateUpdateModelAMixin, OptimizationMixin
|
from .mixins import CreateUpdateModelAMixin, OptimizationMixin
|
||||||
@@ -422,7 +423,9 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
|
|||||||
|
|
||||||
# Ensure forward slashes for URL
|
# Ensure forward slashes for URL
|
||||||
file_name = instance.file.name.replace('\\', '/')
|
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}")
|
logger.info(f"File uploaded: {instance.path}")
|
||||||
instance.save()
|
instance.save()
|
||||||
@@ -438,3 +441,21 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
|
|||||||
file_obj.file.delete(save=False)
|
file_obj.file.delete(save=False)
|
||||||
file_obj.delete(soft=False)
|
file_obj.delete(soft=False)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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 |
|
After Width: | Height: | Size: 91 KiB |