Compare commits

..

19 Commits

Author SHA1 Message Date
admin
13ef10039c Initial commit 2025-12-12 17:26:20 +08:00
admin
a9aefed4be Initial commit 2025-12-12 16:13:04 +08:00
admin
08d645e572 Initial commit 2025-12-12 15:30:00 +08:00
admin
ed352f5a71 Initial commit 2025-12-12 15:20:09 +08:00
admin
8faaf38a08 Initial commit 2025-12-12 15:03:00 +08:00
admin
c4cd2f97e9 Initial commit 2025-12-12 14:58:09 +08:00
admin
8b0ada3cc0 Initial commit 2025-12-12 14:52:10 +08:00
admin
81b5d3f4d8 Initial commit 2025-12-12 14:42:07 +08:00
admin
467b8510a7 Initial commit 2025-12-12 14:38:41 +08:00
admin
05e20501a9 Initial commit 2025-12-12 14:34:58 +08:00
admin
381b7a8d60 Initial commit 2025-12-12 14:32:42 +08:00
admin
c7af3b3e44 Initial commit 2025-12-12 14:06:35 +08:00
admin
61c61a77a2 Initial commit 2025-12-10 17:22:27 +08:00
admin
9292ef8d8d Initial commit 2025-12-09 17:52:52 +08:00
admin
602ce92418 Initial commit 2025-12-09 16:27:48 +08:00
admin
1384bb1d4a Initial commit 2025-12-09 14:37:41 +08:00
admin
b8648c2861 Initial commit 2025-12-09 14:31:16 +08:00
admin
c34b63b8da Initial commit 2025-12-09 09:51:12 +08:00
admin
7b1961894d Initial commit 2025-12-09 09:48:51 +08:00
139 changed files with 3739 additions and 477 deletions

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ __pycache__/
.idea/ .idea/
*.log *.log
.env .env
create_repo.py
*.mp4
.node_modules/

106
BACKEND_API_DOC.md Normal file
View 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
View 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
View 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
View 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] }`

View File

@@ -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
View 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 许可证。

View File

@@ -52,9 +52,25 @@
4. 启动开发服务器: 4. 启动开发服务器:
```powershell > **注意**: 如果你的 Node.js 版本 >= 17可能会遇到 OpenSSL 相关的错误。请在启动前设置环境变量。
npm run dev
``` * **Windows (PowerShell)**:
```powershell
$env:NODE_OPTIONS="--openssl-legacy-provider"
npm run dev
```
* **CMD**:
```cmd
set NODE_OPTIONS=--openssl-legacy-provider
npm run dev
```
* **Linux / macOS**:
```bash
export NODE_OPTIONS=--openssl-legacy-provider
npm run dev
```
5. 启动完成后,浏览器会自动打开 `http://localhost:9528` (或其他配置的端口)。 5. 启动完成后,浏览器会自动打开 `http://localhost:9528` (或其他配置的端口)。

View File

@@ -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可显示资源文件

File diff suppressed because one or more lines are too long

View 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'
})
}

View File

@@ -30,3 +30,12 @@ export function clearFiles() {
method: 'delete' method: 'delete'
}) })
} }
export function replaceUrl(data) {
return request({
url: '/file/replace_url/',
method: 'post',
data
})
}

View File

@@ -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>

View File

@@ -42,41 +42,41 @@ export default {
} }
.sidebar-logo-container { .sidebar-logo-container {
position: relative; position: relative;
width: 100%;
height: 50px;
line-height: 50px;
background: #2b333e;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%; width: 100%;
height: 50px;
line-height: 50px;
background: var(--menuBg);
text-align: center;
overflow: hidden;
& .sidebar-logo { & .sidebar-logo-link {
width: 32px; height: 100%;
height: 32px; width: 100%;
vertical-align: middle;
margin-right: 12px; & .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: var(--menuText);
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
} }
& .sidebar-title { &.collapse {
display: inline-block; .sidebar-logo {
margin: 0; margin-right: 0px;
color: #fff; }
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
} }
} }
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
</style> </style>

View File

@@ -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

View File

@@ -119,16 +119,46 @@ 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: 'students',
name: 'Students',
component: () => import('@/views/crm/student'),
meta: { title: '学员管理', icon: 'peoples' }
},
{
path: 'honors',
name: 'Honors',
component: () => import('@/views/crm/honor'),
meta: { title: '学员荣誉管理', icon: 'medal' }
}
]
}, },
{ {
path: 'issued-coupons', path: 'coupon-manage',
name: 'IssuedCoupons', name: 'CouponManage',
component: () => import('@/views/crm/issued_coupon'), component: () => import('@/views/crm/index'),
meta: { title: '已发优惠券', icon: 'list' } 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', path: 'banners',
@@ -136,23 +166,44 @@ export const asyncRoutes = [
component: () => import('@/views/crm/banner'), component: () => import('@/views/crm/banner'),
meta: { title: '轮播图管理', icon: 'drag' } meta: { title: '轮播图管理', icon: 'drag' }
}, },
{
path: 'students',
name: 'Students',
component: () => import('@/views/crm/student'),
meta: { title: '学员管理', icon: 'peoples' }
},
{
path: 'honors',
name: 'Honors',
component: () => import('@/views/crm/honor'),
meta: { title: '学员荣誉管理', icon: 'medal' }
},
{ {
path: 'showcases', 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: '按优惠券发送' }
}
]
} }
] ]
}, },

View File

@@ -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

View File

@@ -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)
} }
} }

View File

@@ -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;
} }
} }
} }

View File

@@ -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
} }

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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'

View File

@@ -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: '',

View File

@@ -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)'
}
}
}] }]
}) })
} }

View File

@@ -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
}
} }
} }
}, },

View 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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
}
} }
} }
} }

View File

@@ -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()
})
})
} }
} }
}; };

View File

@@ -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
} }
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,11 @@
venv/
__pycache__/
*.pyc
.git/
.vscode/
.vs/
dist/
celerybeat.pid
celerybeat-schedule.*
db.sqlite3
.env

View File

@@ -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):

View File

@@ -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

View 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'],
},
),
]

View 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='是否活跃'),
),
]

View 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='关联批次'),
),
]

View 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='城市'),
),
]

View 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'),
),
]

View File

@@ -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}"

View File

@@ -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__'

View File

@@ -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)),

View File

@@ -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)

View File

@@ -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):
""" """
数据字典类型序列化 数据字典类型序列化

View File

@@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Some files were not shown because too many files have changed in this diff Show More