finish
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -93,7 +93,7 @@ ehthumbs.db
|
|||||||
*.3g2
|
*.3g2
|
||||||
*.asf
|
*.asf
|
||||||
*.rm
|
*.rm
|
||||||
*.rmvb
|
*.rmVB
|
||||||
*.vob
|
*.vob
|
||||||
*.mpg
|
*.mpg
|
||||||
*.mpeg
|
*.mpeg
|
||||||
|
|||||||
220
README.md
220
README.md
@@ -1,220 +0,0 @@
|
|||||||
# 量极AI硬件商城
|
|
||||||
|
|
||||||
一个基于React和Django的AI硬件在线商城系统,提供硬件配置展示、订单管理和支付功能。
|
|
||||||
|
|
||||||
## 🚀 项目概述
|
|
||||||
|
|
||||||
量极AI硬件商城是一个全栈Web应用程序,专注于AI硬件产品的在线销售。系统采用前后端分离架构,前端使用React + Vite + Ant Design,后端使用Django REST Framework。
|
|
||||||
|
|
||||||
## 📋 功能特性
|
|
||||||
|
|
||||||
### 前端功能
|
|
||||||
- 🛍️ 硬件配置展示和选择
|
|
||||||
- 🛒 购物车功能
|
|
||||||
- 📋 订单创建和管理
|
|
||||||
- 💳 支付流程集成
|
|
||||||
- 🔗 推广码支持
|
|
||||||
- 📱 响应式设计
|
|
||||||
|
|
||||||
### 后端功能
|
|
||||||
- 🏪 产品配置管理
|
|
||||||
- 📦 订单处理
|
|
||||||
- 💰 支付接口
|
|
||||||
- 👥 用户管理
|
|
||||||
- 📊 数据统计
|
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
|
||||||
|
|
||||||
### 前端技术
|
|
||||||
- **React 19** - 现代化UI库
|
|
||||||
- **Vite** - 快速构建工具
|
|
||||||
- **Ant Design** - 企业级UI组件库
|
|
||||||
- **React Router** - 路由管理
|
|
||||||
- **Axios** - HTTP客户端
|
|
||||||
|
|
||||||
### 后端技术
|
|
||||||
- **Django 6.0** - Python Web框架
|
|
||||||
- **Django REST Framework** - RESTful API
|
|
||||||
- **PostgreSQL** - 数据库
|
|
||||||
- **CORS Headers** - 跨域支持
|
|
||||||
|
|
||||||
## 📁 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
Quant-Speed_ai_hardware/
|
|
||||||
├── frontend/ # React前端应用
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── components/ # React组件
|
|
||||||
│ │ │ └── HardwareShop.jsx
|
|
||||||
│ │ ├── App.jsx # 主应用组件
|
|
||||||
│ │ ├── api.js # API接口封装
|
|
||||||
│ │ └── main.jsx # 应用入口
|
|
||||||
│ ├── package.json # 前端依赖配置
|
|
||||||
│ └── vite.config.js # Vite配置
|
|
||||||
├── backend/ # Django后端应用
|
|
||||||
│ ├── config/ # Django配置
|
|
||||||
│ │ ├── settings.py # 主配置文件
|
|
||||||
│ │ ├── urls.py # URL路由配置
|
|
||||||
│ │ └── wsgi.py # WSGI配置
|
|
||||||
│ ├── shop/ # 商城应用
|
|
||||||
│ │ ├── models.py # 数据模型
|
|
||||||
│ │ ├── views.py # 视图函数
|
|
||||||
│ │ ├── serializers.py # 序列化器
|
|
||||||
│ │ └── urls.py # 应用路由
|
|
||||||
│ ├── manage.py # Django管理脚本
|
|
||||||
│ └── populate_db.py # 数据库初始化脚本
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
- Node.js 18+
|
|
||||||
- Python 3.8+
|
|
||||||
- PostgreSQL 12+
|
|
||||||
|
|
||||||
### 前端安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入前端目录
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 启动开发服务器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# 构建生产版本
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入后端目录
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 创建虚拟环境
|
|
||||||
python -m venv venv
|
|
||||||
|
|
||||||
# 激活虚拟环境
|
|
||||||
# Windows
|
|
||||||
venv\Scripts\activate
|
|
||||||
# macOS/Linux
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
pip install django djangorestframework django-cors-headers psycopg2-binary
|
|
||||||
|
|
||||||
# 数据库配置
|
|
||||||
# 编辑 config/settings.py 中的数据库配置
|
|
||||||
|
|
||||||
# 运行数据库迁移
|
|
||||||
python manage.py migrate
|
|
||||||
|
|
||||||
# 创建超级用户
|
|
||||||
python manage.py createsuperuser
|
|
||||||
|
|
||||||
# 启动开发服务器
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库初始化
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行数据库填充脚本
|
|
||||||
python populate_db.py
|
|
||||||
```
|
|
||||||
### admin账户:
|
|
||||||
‘
|
|
||||||
jeremygan2021
|
|
||||||
qweasdzxc1
|
|
||||||
’
|
|
||||||
|
|
||||||
## 🔧 配置说明
|
|
||||||
|
|
||||||
### 前端配置
|
|
||||||
- **Vite配置**: `frontend/vite.config.js`
|
|
||||||
- **环境变量**: 支持 `.env` 文件配置
|
|
||||||
|
|
||||||
### 后端配置
|
|
||||||
- **Django设置**: `backend/config/settings.py`
|
|
||||||
- **数据库**: PostgreSQL配置
|
|
||||||
- **CORS**: 跨域请求配置
|
|
||||||
- **国际化**: 中文支持
|
|
||||||
|
|
||||||
## 📡 API接口
|
|
||||||
|
|
||||||
### 硬件配置接口
|
|
||||||
- `GET /api/configs/` - 获取硬件配置列表
|
|
||||||
- `GET /api/configs/{id}/` - 获取特定配置详情
|
|
||||||
|
|
||||||
### 订单接口
|
|
||||||
- `POST /api/orders/` - 创建订单
|
|
||||||
- `GET /api/orders/{id}/` - 获取订单详情
|
|
||||||
- `POST /api/orders/{id}/pay/` - 订单支付
|
|
||||||
|
|
||||||
### 支付接口
|
|
||||||
- `POST /api/payments/initiate/` - 发起支付
|
|
||||||
- `POST /api/payments/confirm/` - 确认支付
|
|
||||||
|
|
||||||
|
|
||||||
## 上传图片接口 不要乱传文件,造成oss存储费用增加
|
|
||||||
### 上传硬件的3D文件(小智参数) zip压缩包,包含3文件和材质文件
|
|
||||||
- `POST https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/market_page/hardware_xiaozhi/product_3D_image` - 上传3D文件
|
|
||||||
|
|
||||||
### 上传硬件的图片(小智参数) 单张图片
|
|
||||||
- `POST https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/market_page/hardware_xiaozhi/product_image` - 上传图片
|
|
||||||
|
|
||||||
## 🎯 使用说明
|
|
||||||
|
|
||||||
### 推广码功能
|
|
||||||
系统支持URL推广码参数,格式:`?ref=推广码`
|
|
||||||
|
|
||||||
### 支付流程
|
|
||||||
1. 选择硬件配置
|
|
||||||
2. 填写订单信息
|
|
||||||
3. 发起支付请求
|
|
||||||
4. 确认支付结果
|
|
||||||
5. 订单完成
|
|
||||||
|
|
||||||
## 🔒 安全说明
|
|
||||||
|
|
||||||
- 生产环境请修改 `SECRET_KEY`
|
|
||||||
- 配置HTTPS证书
|
|
||||||
- 设置适当的CORS白名单
|
|
||||||
- 定期备份数据库
|
|
||||||
|
|
||||||
## 🐛 常见问题
|
|
||||||
|
|
||||||
### 跨域问题
|
|
||||||
确保后端CORS配置正确,开发环境可设置为允许所有来源。
|
|
||||||
|
|
||||||
### 数据库连接失败
|
|
||||||
检查PostgreSQL服务状态和连接配置。
|
|
||||||
|
|
||||||
### 前端构建失败
|
|
||||||
检查Node.js版本和依赖包完整性。
|
|
||||||
|
|
||||||
## 📞 联系方式
|
|
||||||
|
|
||||||
如有问题或建议,请通过以下方式联系:
|
|
||||||
- 邮箱:support@Quant-Speed-ai.com
|
|
||||||
- 电话:400-123-4567
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
|
||||||
|
|
||||||
## 🙏 致谢
|
|
||||||
|
|
||||||
感谢以下开源项目的支持:
|
|
||||||
- [React](https://reactjs.org/)
|
|
||||||
- [Django](https://www.djangoproject.com/)
|
|
||||||
- [Ant Design](https://ant.design/)
|
|
||||||
- [Vite](https://vitejs.dev/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果这个项目对您有帮助,请给我们一个星标!**
|
|
||||||
@@ -12,7 +12,7 @@ links = [
|
|||||||
"admin:shop_distributor_changelist",
|
"admin:shop_distributor_changelist",
|
||||||
"admin:shop_esp32config_changelist",
|
"admin:shop_esp32config_changelist",
|
||||||
"admin:shop_service_changelist",
|
"admin:shop_service_changelist",
|
||||||
"admin:shop_vbcourse_changelist",
|
"admin:shop_VBcourse_changelist",
|
||||||
"admin:shop_order_changelist",
|
"admin:shop_order_changelist",
|
||||||
"admin:shop_serviceorder_changelist",
|
"admin:shop_serviceorder_changelist",
|
||||||
"admin:shop_withdrawal_changelist",
|
"admin:shop_withdrawal_changelist",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -232,9 +232,9 @@ UNFOLD = {
|
|||||||
"link": reverse_lazy("admin:shop_service_changelist"),
|
"link": reverse_lazy("admin:shop_service_changelist"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "VB课程",
|
"title": "VC课程",
|
||||||
"icon": "school",
|
"icon": "school",
|
||||||
"link": reverse_lazy("admin:shop_vbcourse_changelist"),
|
"link": reverse_lazy("admin:shop_vccourse_changelist"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,7 @@ from django.db.models import Sum
|
|||||||
from django import forms
|
from django import forms
|
||||||
from unfold.admin import ModelAdmin, TabularInline
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
from unfold.decorators import display
|
from unfold.decorators import display
|
||||||
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VBCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder
|
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment
|
||||||
import qrcode
|
import qrcode
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import base64
|
import base64
|
||||||
@@ -83,7 +83,7 @@ class ESP32ConfigAdmin(ModelAdmin):
|
|||||||
inlines = [ProductFeatureInline]
|
inlines = [ProductFeatureInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
'fields': ('name', 'price', 'stock', 'description')
|
'fields': ('name', 'price', 'stock', 'commission_rate', 'description')
|
||||||
}),
|
}),
|
||||||
('硬件参数', {
|
('硬件参数', {
|
||||||
'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone')
|
'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone')
|
||||||
@@ -141,17 +141,21 @@ class ServiceOrderAdmin(ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.register(VBCourse)
|
@admin.register(VCCourse)
|
||||||
class VBCourseAdmin(ModelAdmin):
|
class VCCourseAdmin(ModelAdmin):
|
||||||
list_display = ('title', 'course_type', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at')
|
list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at')
|
||||||
search_fields = ('title', 'description', 'instructor', 'tag')
|
search_fields = ('title', 'description', 'instructor', 'tag')
|
||||||
list_filter = ('course_type', 'instructor', 'tag')
|
list_filter = ('course_type', 'instructor', 'tag')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
'fields': ('title', 'description', 'course_type', 'tag')
|
'fields': ('title', 'description', 'course_type', 'tag', 'price')
|
||||||
|
}),
|
||||||
|
('讲师信息', {
|
||||||
|
'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'),
|
||||||
|
'description': '讲师头像上传和URL二选一,优先使用URL'
|
||||||
}),
|
}),
|
||||||
('课程详情', {
|
('课程详情', {
|
||||||
'fields': ('instructor', 'duration', 'lesson_count')
|
'fields': ('duration', 'lesson_count', 'content')
|
||||||
}),
|
}),
|
||||||
('封面', {
|
('封面', {
|
||||||
'fields': ('cover_image', 'cover_image_url'),
|
'fields': ('cover_image', 'cover_image_url'),
|
||||||
@@ -163,6 +167,24 @@ class VBCourseAdmin(ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.register(CourseEnrollment)
|
||||||
|
class CourseEnrollmentAdmin(ModelAdmin):
|
||||||
|
list_display = ('customer_name', 'course', 'phone_number', 'status', 'created_at')
|
||||||
|
list_filter = ('status', 'course', 'created_at')
|
||||||
|
search_fields = ('customer_name', 'phone_number', 'wechat_id')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('报名信息', {
|
||||||
|
'fields': ('course', 'status', 'created_at')
|
||||||
|
}),
|
||||||
|
('客户资料', {
|
||||||
|
'fields': ('customer_name', 'phone_number', 'wechat_id', 'email', 'message')
|
||||||
|
}),
|
||||||
|
('销售归属', {
|
||||||
|
'fields': ('salesperson', 'distributor')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
@admin.register(Salesperson)
|
@admin.register(Salesperson)
|
||||||
class SalespersonAdmin(ModelAdmin):
|
class SalespersonAdmin(ModelAdmin):
|
||||||
list_display = ('name', 'code', 'total_sales', 'view_promotion_url')
|
list_display = ('name', 'code', 'total_sales', 'view_promotion_url')
|
||||||
@@ -240,14 +262,14 @@ class SalespersonAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(CommissionLog)
|
@admin.register(CommissionLog)
|
||||||
class CommissionLogAdmin(ModelAdmin):
|
class CommissionLogAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'salesperson', 'amount', 'level', 'status', 'created_at')
|
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at')
|
||||||
list_filter = ('status', 'level', 'salesperson', 'created_at')
|
list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at')
|
||||||
search_fields = ('salesperson__name', 'order__id')
|
search_fields = ('salesperson__name', 'distributor__user__nickname', 'order__id')
|
||||||
readonly_fields = ('amount', 'level', 'created_at')
|
readonly_fields = ('amount', 'level', 'created_at')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
'fields': ('salesperson', 'order', 'amount', 'level')
|
'fields': ('salesperson', 'distributor', 'order', 'amount', 'level')
|
||||||
}),
|
}),
|
||||||
('状态管理', {
|
('状态管理', {
|
||||||
'fields': ('status', 'created_at')
|
'fields': ('status', 'created_at')
|
||||||
@@ -256,23 +278,31 @@ class CommissionLogAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Order)
|
@admin.register(Order)
|
||||||
class OrderAdmin(ModelAdmin):
|
class OrderAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'customer_name', 'config', 'total_price', 'status', 'courier_name', 'tracking_number', 'salesperson', 'created_at')
|
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
|
||||||
list_filter = ('status', 'salesperson', 'created_at')
|
list_filter = ('status', 'salesperson', 'distributor', 'created_at')
|
||||||
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no')
|
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no')
|
||||||
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
||||||
|
|
||||||
|
def get_item_name(self, obj):
|
||||||
|
if obj.config:
|
||||||
|
return f"[硬件] {obj.config.name}"
|
||||||
|
if obj.course:
|
||||||
|
return f"[课程] {obj.course.title}"
|
||||||
|
return "未知商品"
|
||||||
|
get_item_name.short_description = "购买商品"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('订单信息', {
|
('订单信息', {
|
||||||
'fields': ('config', 'quantity', 'total_price', 'status', 'created_at')
|
'fields': ('config', 'course', 'quantity', 'total_price', 'status', 'created_at')
|
||||||
}),
|
}),
|
||||||
('客户信息', {
|
('客户信息', {
|
||||||
'fields': ('customer_name', 'phone_number', 'shipping_address')
|
'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user')
|
||||||
}),
|
}),
|
||||||
('物流信息', {
|
('物流信息', {
|
||||||
'fields': ('courier_name', 'tracking_number')
|
'fields': ('courier_name', 'tracking_number')
|
||||||
}),
|
}),
|
||||||
('销售归属', {
|
('销售归属', {
|
||||||
'fields': ('salesperson',)
|
'fields': ('salesperson', 'distributor')
|
||||||
}),
|
}),
|
||||||
('支付信息', {
|
('支付信息', {
|
||||||
'fields': ('wechat_trade_no',)
|
'fields': ('wechat_trade_no',)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-10 19:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0020_alter_vbcourse_course_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commissionlog',
|
||||||
|
name='distributor',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.distributor', verbose_name='获佣分销员'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='distributor',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.distributor', verbose_name='所属分销员'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='commissionlog',
|
||||||
|
name='salesperson',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-10 19:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0021_commissionlog_distributor_order_distributor_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='content',
|
||||||
|
field=models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='price',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CourseEnrollment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('customer_name', models.CharField(max_length=100, verbose_name='姓名')),
|
||||||
|
('phone_number', models.CharField(max_length=20, verbose_name='联系电话')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')),
|
||||||
|
('wechat_id', models.CharField(blank=True, max_length=50, verbose_name='微信号')),
|
||||||
|
('message', models.TextField(blank=True, verbose_name='留言/备注')),
|
||||||
|
('status', models.CharField(choices=[('pending', '待联系'), ('contacted', '已联系'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vbcourse', verbose_name='咨询课程')),
|
||||||
|
('distributor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.distributor', verbose_name='所属分销员')),
|
||||||
|
('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '课程报名',
|
||||||
|
'verbose_name_plural': '课程报名管理',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-10 19:23
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0022_vbcourse_content_vbcourse_price_courseenrollment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='course',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vbcourse', verbose_name='所选课程'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='config',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-10 19:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0023_order_course_alter_order_config'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='instructor_avatar',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='instructor_avatar_url',
|
||||||
|
field=models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='instructor_desc',
|
||||||
|
field=models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vbcourse',
|
||||||
|
name='instructor_title',
|
||||||
|
field=models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-10 19:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0024_vbcourse_instructor_avatar_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VCCourse',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=100, verbose_name='课程名称')),
|
||||||
|
('description', models.TextField(verbose_name='课程简介')),
|
||||||
|
('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型')),
|
||||||
|
('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')),
|
||||||
|
('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')),
|
||||||
|
('instructor', models.CharField(default='VC讲师', max_length=50, verbose_name='讲师')),
|
||||||
|
('instructor_title', models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔')),
|
||||||
|
('instructor_avatar', models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)')),
|
||||||
|
('instructor_avatar_url', models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)')),
|
||||||
|
('instructor_desc', models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介')),
|
||||||
|
('tag', models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签')),
|
||||||
|
('price', models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格')),
|
||||||
|
('content', models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容')),
|
||||||
|
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')),
|
||||||
|
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')),
|
||||||
|
('detail_image', models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)')),
|
||||||
|
('detail_image_url', models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'VC课程',
|
||||||
|
'verbose_name_plural': 'VC课程管理',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='courseenrollment',
|
||||||
|
name='course',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vccourse', verbose_name='咨询课程'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='course',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vccourse', verbose_name='所选课程'),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='VBCourse',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -165,7 +165,8 @@ class CommissionLog(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
order = models.ForeignKey('Order', on_delete=models.CASCADE, verbose_name="关联订单", related_name='commissions')
|
order = models.ForeignKey('Order', on_delete=models.CASCADE, verbose_name="关联订单", related_name='commissions')
|
||||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.CASCADE, verbose_name="获佣销售员", related_name='commissions')
|
salesperson = models.ForeignKey(Salesperson, on_delete=models.CASCADE, verbose_name="获佣销售员", related_name='commissions', null=True, blank=True)
|
||||||
|
distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, verbose_name="获佣分销员", related_name='commissions', null=True, blank=True)
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="佣金金额")
|
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="佣金金额")
|
||||||
level = models.IntegerField(default=1, verbose_name="分销层级", help_text="1: 直接销售, 2: 二级分销")
|
level = models.IntegerField(default=1, verbose_name="分销层级", help_text="1: 直接销售, 2: 二级分销")
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||||
@@ -219,13 +220,15 @@ class Order(models.Model):
|
|||||||
('cancelled', '已取消'),
|
('cancelled', '已取消'),
|
||||||
)
|
)
|
||||||
|
|
||||||
config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置")
|
config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True)
|
||||||
|
course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders')
|
||||||
quantity = models.IntegerField(default=1, verbose_name="数量")
|
quantity = models.IntegerField(default=1, verbose_name="数量")
|
||||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价")
|
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价")
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
|
||||||
|
|
||||||
# 销售归属
|
# 销售归属
|
||||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员", related_name='orders')
|
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员", related_name='orders')
|
||||||
|
distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员", related_name='orders')
|
||||||
|
|
||||||
# 关联微信用户
|
# 关联微信用户
|
||||||
wechat_user = models.ForeignKey(WeChatUser, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="下单微信用户", related_name='orders')
|
wechat_user = models.ForeignKey(WeChatUser, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="下单微信用户", related_name='orders')
|
||||||
@@ -312,9 +315,9 @@ class ServiceOrder(models.Model):
|
|||||||
verbose_name_plural = "服务订单列表"
|
verbose_name_plural = "服务订单列表"
|
||||||
|
|
||||||
|
|
||||||
class VBCourse(models.Model):
|
class VCCourse(models.Model):
|
||||||
"""
|
"""
|
||||||
VB Coding 课程模型
|
VC (VB Coding) 课程模型
|
||||||
"""
|
"""
|
||||||
COURSE_TYPE_CHOICES = (
|
COURSE_TYPE_CHOICES = (
|
||||||
('software', '软件课程'),
|
('software', '软件课程'),
|
||||||
@@ -327,10 +330,17 @@ class VBCourse(models.Model):
|
|||||||
course_type = models.CharField(max_length=20, choices=COURSE_TYPE_CHOICES, default='software', verbose_name="课程类型")
|
course_type = models.CharField(max_length=20, choices=COURSE_TYPE_CHOICES, default='software', verbose_name="课程类型")
|
||||||
duration = models.CharField(max_length=50, verbose_name="课程时长", help_text="例如: 30分钟", default="30分钟")
|
duration = models.CharField(max_length=50, verbose_name="课程时长", help_text="例如: 30分钟", default="30分钟")
|
||||||
lesson_count = models.IntegerField(default=1, verbose_name="课时数量")
|
lesson_count = models.IntegerField(default=1, verbose_name="课时数量")
|
||||||
instructor = models.CharField(max_length=50, verbose_name="讲师", default="VB讲师")
|
instructor = models.CharField(max_length=50, verbose_name="讲师", default="VC讲师")
|
||||||
|
instructor_title = models.CharField(max_length=50, verbose_name="讲师头衔", default="资深讲师")
|
||||||
|
instructor_avatar = models.ImageField(upload_to='instructors/avatars/', blank=True, null=True, verbose_name="讲师头像 (上传)")
|
||||||
|
instructor_avatar_url = models.URLField(blank=True, null=True, verbose_name="讲师头像 (URL)")
|
||||||
|
instructor_desc = models.TextField(blank=True, verbose_name="讲师简介", default="拥有多年开发经验,擅长...")
|
||||||
|
|
||||||
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
|
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
|
||||||
|
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费")
|
||||||
|
content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML")
|
||||||
|
|
||||||
cover_image = models.ImageField(upload_to='courses/covers/', blank=True, null=True, verbose_name="封面图 (上传)")
|
cover_image = models.ImageField(upload_to='courses/covers/', blank=True, null=True, verbose_name="封面图 (上传)")
|
||||||
cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)")
|
cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)")
|
||||||
|
|
||||||
@@ -343,5 +353,40 @@ class VBCourse(models.Model):
|
|||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "VB课程"
|
verbose_name = "VC课程"
|
||||||
verbose_name_plural = "VB课程管理"
|
verbose_name_plural = "VC课程管理"
|
||||||
|
|
||||||
|
|
||||||
|
class CourseEnrollment(models.Model):
|
||||||
|
"""
|
||||||
|
课程报名/咨询记录
|
||||||
|
"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('pending', '待联系'),
|
||||||
|
('contacted', '已联系'),
|
||||||
|
('completed', '已完成'),
|
||||||
|
('cancelled', '已取消'),
|
||||||
|
)
|
||||||
|
|
||||||
|
course = models.ForeignKey(VCCourse, on_delete=models.CASCADE, verbose_name="咨询课程", related_name='enrollments')
|
||||||
|
customer_name = models.CharField(max_length=100, verbose_name="姓名")
|
||||||
|
phone_number = models.CharField(max_length=20, verbose_name="联系电话")
|
||||||
|
email = models.EmailField(blank=True, verbose_name="电子邮箱")
|
||||||
|
wechat_id = models.CharField(max_length=50, blank=True, verbose_name="微信号")
|
||||||
|
message = models.TextField(blank=True, verbose_name="留言/备注")
|
||||||
|
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||||
|
|
||||||
|
# 销售归属
|
||||||
|
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员")
|
||||||
|
distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.customer_name} - {self.course.title}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "课程报名"
|
||||||
|
verbose_name_plural = "课程报名管理"
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import ESP32Config, Order, Salesperson, Service, VBCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal
|
from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment
|
||||||
|
|
||||||
|
class CommissionLogSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
佣金记录序列化器
|
||||||
|
"""
|
||||||
|
order_info = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CommissionLog
|
||||||
|
fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info']
|
||||||
|
read_only_fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info']
|
||||||
|
|
||||||
|
def get_order_info(self, obj):
|
||||||
|
return {
|
||||||
|
'order_id': obj.order.id,
|
||||||
|
'total_price': obj.order.total_price,
|
||||||
|
'customer_name': obj.order.customer_name
|
||||||
|
}
|
||||||
|
|
||||||
class WeChatUserSerializer(serializers.ModelSerializer):
|
class WeChatUserSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -69,6 +87,37 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
return obj.detail_image.url
|
return obj.detail_image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class CourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
课程报名序列化器
|
||||||
|
"""
|
||||||
|
course_title = serializers.CharField(source='course.title', read_only=True)
|
||||||
|
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CourseEnrollment
|
||||||
|
fields = ['id', 'course', 'course_title', 'customer_name', 'phone_number', 'email', 'wechat_id', 'message', 'status', 'created_at', 'ref_code']
|
||||||
|
read_only_fields = ['status', 'created_at']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
ref_code = validated_data.pop('ref_code', None)
|
||||||
|
|
||||||
|
# 尝试关联销售员或分销员
|
||||||
|
if ref_code:
|
||||||
|
try:
|
||||||
|
salesperson = Salesperson.objects.get(code=ref_code)
|
||||||
|
validated_data['salesperson'] = salesperson
|
||||||
|
except Salesperson.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
distributor = Distributor.objects.get(invite_code=ref_code)
|
||||||
|
validated_data['distributor'] = distributor
|
||||||
|
except Distributor.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
class ServiceOrderSerializer(serializers.ModelSerializer):
|
class ServiceOrderSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
AI服务订单序列化器
|
AI服务订单序列化器
|
||||||
@@ -101,16 +150,16 @@ class ServiceOrderSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
class VBCourseSerializer(serializers.ModelSerializer):
|
class VCCourseSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
VB课程序列化器
|
VC课程序列化器
|
||||||
"""
|
"""
|
||||||
display_cover_image = serializers.SerializerMethodField()
|
display_cover_image = serializers.SerializerMethodField()
|
||||||
display_detail_image = serializers.SerializerMethodField()
|
display_detail_image = serializers.SerializerMethodField()
|
||||||
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
|
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VBCourse
|
model = VCCourse
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
def get_display_cover_image(self, obj):
|
def get_display_cover_image(self, obj):
|
||||||
@@ -151,6 +200,7 @@ class OrderSerializer(serializers.ModelSerializer):
|
|||||||
订单序列化器
|
订单序列化器
|
||||||
"""
|
"""
|
||||||
config_name = serializers.CharField(source='config.name', read_only=True)
|
config_name = serializers.CharField(source='config.name', read_only=True)
|
||||||
|
course_title = serializers.CharField(source='course.title', read_only=True)
|
||||||
config_image = serializers.SerializerMethodField()
|
config_image = serializers.SerializerMethodField()
|
||||||
salesperson_name = serializers.CharField(source='salesperson.name', read_only=True)
|
salesperson_name = serializers.CharField(source='salesperson.name', read_only=True)
|
||||||
salesperson_code = serializers.CharField(source='salesperson.code', read_only=True)
|
salesperson_code = serializers.CharField(source='salesperson.code', read_only=True)
|
||||||
@@ -159,41 +209,76 @@ class OrderSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ['id', 'config', 'config_name', 'config_image', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no',
|
fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no',
|
||||||
'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number']
|
'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number']
|
||||||
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at']
|
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at']
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'customer_name': {'required': True},
|
'customer_name': {'required': True},
|
||||||
'phone_number': {'required': True},
|
'phone_number': {'required': True},
|
||||||
'shipping_address': {'required': True},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
# 如果是部分更新 (PATCH),可能不需要校验所有字段,但这里主要用于创建
|
||||||
|
if self.instance:
|
||||||
|
return data
|
||||||
|
|
||||||
|
config = data.get('config')
|
||||||
|
course = data.get('course')
|
||||||
|
|
||||||
|
if not config and not course:
|
||||||
|
raise serializers.ValidationError("必须选择一种商品(硬件配置或课程)")
|
||||||
|
|
||||||
|
if config and course:
|
||||||
|
raise serializers.ValidationError("一次只能购买一种类型的商品")
|
||||||
|
|
||||||
|
if config and not data.get('shipping_address'):
|
||||||
|
raise serializers.ValidationError({"shipping_address": "购买硬件产品需要填写收货地址"})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
def get_config_image(self, obj):
|
def get_config_image(self, obj):
|
||||||
|
if obj.config:
|
||||||
if obj.config.static_image_url:
|
if obj.config.static_image_url:
|
||||||
return obj.config.static_image_url
|
return obj.config.static_image_url
|
||||||
if obj.config.detail_image_url:
|
if obj.config.detail_image_url:
|
||||||
return obj.config.detail_image_url
|
return obj.config.detail_image_url
|
||||||
if obj.config.detail_image:
|
if obj.config.detail_image:
|
||||||
return obj.config.detail_image.url
|
return obj.config.detail_image.url
|
||||||
|
elif obj.course:
|
||||||
|
if obj.course.cover_image_url:
|
||||||
|
return obj.course.cover_image_url
|
||||||
|
if obj.course.cover_image:
|
||||||
|
return obj.course.cover_image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""
|
"""
|
||||||
重写创建方法,自动计算总价并关联销售员
|
重写创建方法,自动计算总价并关联销售员/分销员
|
||||||
"""
|
"""
|
||||||
config = validated_data.get('config')
|
config = validated_data.get('config')
|
||||||
|
course = validated_data.get('course')
|
||||||
quantity = validated_data.get('quantity', 1)
|
quantity = validated_data.get('quantity', 1)
|
||||||
ref_code = validated_data.pop('ref_code', None)
|
ref_code = validated_data.pop('ref_code', None)
|
||||||
|
|
||||||
|
if config:
|
||||||
validated_data['total_price'] = config.price * quantity
|
validated_data['total_price'] = config.price * quantity
|
||||||
|
elif course:
|
||||||
|
validated_data['total_price'] = course.price * quantity
|
||||||
|
|
||||||
# 尝试关联销售员
|
# 尝试关联销售员或分销员
|
||||||
if ref_code:
|
if ref_code:
|
||||||
|
# 1. 尝试查找旧版销售员
|
||||||
try:
|
try:
|
||||||
salesperson = Salesperson.objects.get(code=ref_code)
|
salesperson = Salesperson.objects.get(code=ref_code)
|
||||||
validated_data['salesperson'] = salesperson
|
validated_data['salesperson'] = salesperson
|
||||||
except Salesperson.DoesNotExist:
|
except Salesperson.DoesNotExist:
|
||||||
# 如果找不到对应的销售员,忽略该推广码,仍继续创建订单(算作自然流量)
|
pass
|
||||||
|
|
||||||
|
# 2. 尝试查找新版分销员
|
||||||
|
try:
|
||||||
|
distributor = Distributor.objects.get(invite_code=ref_code)
|
||||||
|
validated_data['distributor'] = distributor
|
||||||
|
except Distributor.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ from django.urls import path, include, re_path
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import (
|
from .views import (
|
||||||
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
||||||
ServiceViewSet, VBCourseViewSet, ServiceOrderViewSet,
|
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
|
||||||
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet
|
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
|
||||||
|
CourseEnrollmentViewSet
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'configs', ESP32ConfigViewSet)
|
router.register(r'configs', ESP32ConfigViewSet)
|
||||||
router.register(r'orders', OrderViewSet)
|
router.register(r'orders', OrderViewSet)
|
||||||
router.register(r'services', ServiceViewSet)
|
router.register(r'services', ServiceViewSet)
|
||||||
router.register(r'courses', VBCourseViewSet)
|
router.register(r'courses', VCCourseViewSet)
|
||||||
|
router.register(r'course-enrollments', CourseEnrollmentViewSet)
|
||||||
router.register(r'service-orders', ServiceOrderViewSet)
|
router.register(r'service-orders', ServiceOrderViewSet)
|
||||||
router.register(r'distributor', DistributorViewSet, basename='distributor')
|
router.register(r'distributor', DistributorViewSet, basename='distributor')
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from django.shortcuts import render
|
|||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
||||||
from .models import ESP32Config, Order, WeChatPayConfig, Service, VBCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal
|
from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment
|
||||||
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VBCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer
|
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer
|
||||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from wechatpayv3 import WeChatPay, WeChatPayType
|
from wechatpayv3 import WeChatPay, WeChatPayType
|
||||||
@@ -215,6 +215,7 @@ def pay(request):
|
|||||||
|
|
||||||
# 1. 获取并验证请求参数
|
# 1. 获取并验证请求参数
|
||||||
good_id = request.data.get('goodid')
|
good_id = request.data.get('goodid')
|
||||||
|
order_type = request.data.get('type', 'config') # 默认为 config
|
||||||
quantity = int(request.data.get('quantity', 1))
|
quantity = int(request.data.get('quantity', 1))
|
||||||
customer_name = request.data.get('customer_name')
|
customer_name = request.data.get('customer_name')
|
||||||
phone_number = request.data.get('phone_number')
|
phone_number = request.data.get('phone_number')
|
||||||
@@ -237,13 +238,21 @@ def pay(request):
|
|||||||
return Response({'error': f'支付配置错误: {error_msg}'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': f'支付配置错误: {error_msg}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 3. 查找商品和销售员,创建订单
|
# 3. 查找商品和销售员,创建订单
|
||||||
|
product = None
|
||||||
|
if order_type == 'course':
|
||||||
|
try:
|
||||||
|
product = VBCourse.objects.get(id=good_id)
|
||||||
|
except VBCourse.DoesNotExist:
|
||||||
|
print(f"课程不存在: {good_id}")
|
||||||
|
return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
product = ESP32Config.objects.get(id=good_id)
|
product = ESP32Config.objects.get(id=good_id)
|
||||||
except ESP32Config.DoesNotExist:
|
except ESP32Config.DoesNotExist:
|
||||||
print(f"商品不存在: {good_id}")
|
print(f"商品不存在: {good_id}")
|
||||||
return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
# 检查库存
|
# 检查库存 (仅针对硬件)
|
||||||
if product.stock < quantity:
|
if product.stock < quantity:
|
||||||
return Response({'error': f'库存不足,仅剩 {product.stock} 件'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': f'库存不足,仅剩 {product.stock} 件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -255,23 +264,33 @@ def pay(request):
|
|||||||
total_price = product.price * quantity
|
total_price = product.price * quantity
|
||||||
amount_in_cents = int(total_price * 100)
|
amount_in_cents = int(total_price * 100)
|
||||||
|
|
||||||
order = Order.objects.create(
|
order_kwargs = {
|
||||||
config=product,
|
'quantity': quantity,
|
||||||
quantity=quantity,
|
'total_price': total_price,
|
||||||
total_price=total_price,
|
'customer_name': customer_name,
|
||||||
customer_name=customer_name,
|
'phone_number': phone_number,
|
||||||
phone_number=phone_number,
|
'shipping_address': shipping_address,
|
||||||
shipping_address=shipping_address,
|
'salesperson': salesperson,
|
||||||
salesperson=salesperson,
|
'status': 'pending'
|
||||||
status='pending'
|
}
|
||||||
)
|
|
||||||
|
|
||||||
# 扣减库存
|
if order_type == 'course':
|
||||||
|
order_kwargs['course'] = product
|
||||||
|
else:
|
||||||
|
order_kwargs['config'] = product
|
||||||
|
|
||||||
|
order = Order.objects.create(**order_kwargs)
|
||||||
|
|
||||||
|
# 扣减库存 (仅针对硬件)
|
||||||
|
if order_type != 'course':
|
||||||
product.stock -= quantity
|
product.stock -= quantity
|
||||||
product.save()
|
product.save()
|
||||||
|
|
||||||
# 4. 调用微信支付接口
|
# 4. 调用微信支付接口
|
||||||
out_trade_no = f"PAY{order.id}T{int(time.time())}"
|
out_trade_no = f"PAY{order.id}T{int(time.time())}"
|
||||||
|
if order_type == 'course':
|
||||||
|
description = f"报名 {product.title}"
|
||||||
|
else:
|
||||||
description = f"购买 {product.name} x {quantity}"
|
description = f"购买 {product.name} x {quantity}"
|
||||||
|
|
||||||
# 保存商户订单号到数据库,方便后续查询
|
# 保存商户订单号到数据库,方便后续查询
|
||||||
@@ -459,13 +478,19 @@ def payment_finish(request):
|
|||||||
order.save()
|
order.save()
|
||||||
print(f"订单 {order.id} 状态已更新")
|
print(f"订单 {order.id} 状态已更新")
|
||||||
|
|
||||||
# 计算佣金
|
# 计算佣金 (旧版销售员系统)
|
||||||
try:
|
try:
|
||||||
salesperson = order.salesperson
|
salesperson = order.salesperson
|
||||||
if salesperson:
|
if salesperson:
|
||||||
# 1. 计算直接佣金 (一级)
|
# 1. 计算直接佣金 (一级)
|
||||||
# 优先级: 产品独立分润比例 > 销售员个人分润比例
|
# 优先级: 产品独立分润比例 > 销售员个人分润比例
|
||||||
|
rate_1 = 0
|
||||||
|
if order.config:
|
||||||
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate
|
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate
|
||||||
|
elif order.course:
|
||||||
|
# 课程暂时使用销售员默认比例
|
||||||
|
rate_1 = salesperson.commission_rate
|
||||||
|
|
||||||
amount_1 = order.total_price * rate_1
|
amount_1 = order.total_price * rate_1
|
||||||
|
|
||||||
if amount_1 > 0:
|
if amount_1 > 0:
|
||||||
@@ -476,7 +501,7 @@ def payment_finish(request):
|
|||||||
level=1,
|
level=1,
|
||||||
status='pending'
|
status='pending'
|
||||||
)
|
)
|
||||||
print(f"生成一级佣金: {salesperson.name} - {amount_1}")
|
print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}")
|
||||||
|
|
||||||
# 2. 计算上级佣金 (二级)
|
# 2. 计算上级佣金 (二级)
|
||||||
parent = salesperson.parent
|
parent = salesperson.parent
|
||||||
@@ -492,9 +517,61 @@ def payment_finish(request):
|
|||||||
level=2,
|
level=2,
|
||||||
status='pending'
|
status='pending'
|
||||||
)
|
)
|
||||||
print(f"生成二级佣金: {parent.name} - {amount_2}")
|
print(f"生成二级佣金(Salesperson): {parent.name} - {amount_2}")
|
||||||
|
|
||||||
|
# 计算佣金 (新版分销员系统)
|
||||||
|
distributor = order.distributor
|
||||||
|
if distributor:
|
||||||
|
# 1. 计算直接佣金 (一级)
|
||||||
|
# 优先级: 产品独立分润比例 > 分销员个人分润比例
|
||||||
|
rate_1 = 0
|
||||||
|
if order.config:
|
||||||
|
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else distributor.commission_rate
|
||||||
|
elif order.course:
|
||||||
|
# 课程暂时使用分销员默认比例
|
||||||
|
rate_1 = distributor.commission_rate
|
||||||
|
|
||||||
|
amount_1 = order.total_price * rate_1
|
||||||
|
|
||||||
|
if amount_1 > 0:
|
||||||
|
CommissionLog.objects.create(
|
||||||
|
order=order,
|
||||||
|
distributor=distributor,
|
||||||
|
amount=amount_1,
|
||||||
|
level=1,
|
||||||
|
status='settled' # 简化流程,直接结算到余额
|
||||||
|
)
|
||||||
|
# 更新余额
|
||||||
|
distributor.total_earnings += amount_1
|
||||||
|
distributor.withdrawable_balance += amount_1
|
||||||
|
distributor.save()
|
||||||
|
print(f"生成一级佣金(Distributor): {distributor.user.nickname} - {amount_1}")
|
||||||
|
|
||||||
|
# 2. 计算上级佣金 (二级)
|
||||||
|
parent = distributor.parent
|
||||||
|
if parent:
|
||||||
|
# 二级固定比例 2% (0.02)
|
||||||
|
rate_2 = 0.02
|
||||||
|
amount_2 = order.total_price * models.DecimalField(max_digits=5, decimal_places=4).to_python(rate_2)
|
||||||
|
|
||||||
|
if amount_2 > 0:
|
||||||
|
CommissionLog.objects.create(
|
||||||
|
order=order,
|
||||||
|
distributor=parent,
|
||||||
|
amount=amount_2,
|
||||||
|
level=2,
|
||||||
|
status='settled'
|
||||||
|
)
|
||||||
|
# 更新余额
|
||||||
|
parent.total_earnings += amount_2
|
||||||
|
parent.withdrawable_balance += amount_2
|
||||||
|
parent.save()
|
||||||
|
print(f"生成二级佣金(Distributor): {parent.user.nickname} - {amount_2}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"佣金计算失败: {str(e)}")
|
print(f"佣金计算失败: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"订单更新失败: {str(e)}")
|
print(f"订单更新失败: {str(e)}")
|
||||||
@@ -508,15 +585,22 @@ def payment_finish(request):
|
|||||||
return HttpResponse(str(e), status=500)
|
return HttpResponse(str(e), status=500)
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(summary="获取VB课程列表", description="获取所有可用的VB课程"),
|
list=extend_schema(summary="获取VC课程列表", description="获取所有可用的VC课程"),
|
||||||
retrieve=extend_schema(summary="获取VB课程详情", description="获取指定VB课程的详细信息")
|
retrieve=extend_schema(summary="获取VC课程详情", description="获取指定VC课程的详细信息")
|
||||||
)
|
)
|
||||||
class VBCourseViewSet(viewsets.ReadOnlyModelViewSet):
|
class VCCourseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
VB课程列表和详情
|
VC课程列表和详情
|
||||||
"""
|
"""
|
||||||
queryset = VBCourse.objects.all().order_by('-created_at')
|
queryset = VCCourse.objects.all().order_by('-created_at')
|
||||||
serializer_class = VBCourseSerializer
|
serializer_class = VCCourseSerializer
|
||||||
|
|
||||||
|
class CourseEnrollmentViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
课程报名管理
|
||||||
|
"""
|
||||||
|
queryset = CourseEnrollment.objects.all().order_by('-created_at')
|
||||||
|
serializer_class = CourseEnrollmentSerializer
|
||||||
|
|
||||||
def order_check_view(request):
|
def order_check_view(request):
|
||||||
"""
|
"""
|
||||||
@@ -989,3 +1073,64 @@ class DistributorViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
return Response({'message': 'Withdrawal request submitted'})
|
return Response({'message': 'Withdrawal request submitted'})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def earnings(self, request):
|
||||||
|
"""查看个人分销金额及明细"""
|
||||||
|
user = get_current_wechat_user(request)
|
||||||
|
if not user or not hasattr(user, 'distributor'):
|
||||||
|
return Response({'error': 'Unauthorized'}, status=401)
|
||||||
|
|
||||||
|
distributor = user.distributor
|
||||||
|
logs = CommissionLog.objects.filter(distributor=distributor).order_by('-created_at')
|
||||||
|
|
||||||
|
page = self.paginate_queryset(logs)
|
||||||
|
if page is not None:
|
||||||
|
serializer = CommissionLogSerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = CommissionLogSerializer(logs, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def team(self, request):
|
||||||
|
"""查看团队(二级分销情况)"""
|
||||||
|
user = get_current_wechat_user(request)
|
||||||
|
if not user or not hasattr(user, 'distributor'):
|
||||||
|
return Response({'error': 'Unauthorized'}, status=401)
|
||||||
|
|
||||||
|
distributor = user.distributor
|
||||||
|
|
||||||
|
# 直推下级
|
||||||
|
children = Distributor.objects.filter(parent=distributor)
|
||||||
|
children_data = DistributorSerializer(children, many=True).data
|
||||||
|
|
||||||
|
# 二级分销收益统计
|
||||||
|
second_level_earnings = CommissionLog.objects.filter(distributor=distributor, level=2).aggregate(total=models.Sum('amount'))['total'] or 0
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'children_count': children.count(),
|
||||||
|
'children': children_data,
|
||||||
|
'second_level_earnings': second_level_earnings
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def orders(self, request):
|
||||||
|
"""查看分销订单"""
|
||||||
|
user = get_current_wechat_user(request)
|
||||||
|
if not user or not hasattr(user, 'distributor'):
|
||||||
|
return Response({'error': 'Unauthorized'}, status=401)
|
||||||
|
|
||||||
|
distributor = user.distributor
|
||||||
|
# 查找我赚了钱的订单
|
||||||
|
commission_logs = CommissionLog.objects.filter(distributor=distributor).select_related('order')
|
||||||
|
order_ids = commission_logs.values_list('order_id', flat=True)
|
||||||
|
orders = Order.objects.filter(id__in=order_ids).order_by('-created_at')
|
||||||
|
|
||||||
|
page = self.paginate_queryset(orders)
|
||||||
|
if page is not None:
|
||||||
|
serializer = OrderSerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = OrderSerializer(orders, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import ProductDetail from './pages/ProductDetail';
|
|||||||
import Payment from './pages/Payment';
|
import Payment from './pages/Payment';
|
||||||
import AIServices from './pages/AIServices';
|
import AIServices from './pages/AIServices';
|
||||||
import ServiceDetail from './pages/ServiceDetail';
|
import ServiceDetail from './pages/ServiceDetail';
|
||||||
import VBCourses from './pages/VBCourses';
|
import VCCourses from './pages/VCCourses';
|
||||||
|
import VCCourseDetail from './pages/VCCourseDetail';
|
||||||
import MyOrders from './pages/MyOrders';
|
import MyOrders from './pages/MyOrders';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -19,7 +20,8 @@ function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/services" element={<AIServices />} />
|
<Route path="/services" element={<AIServices />} />
|
||||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||||
<Route path="/courses" element={<VBCourses />} />
|
<Route path="/courses" element={<VCCourses />} />
|
||||||
|
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||||
<Route path="/my-orders" element={<MyOrders />} />
|
<Route path="/my-orders" element={<MyOrders />} />
|
||||||
<Route path="/product/:id" element={<ProductDetail />} />
|
<Route path="/product/:id" element={<ProductDetail />} />
|
||||||
<Route path="/payment/:orderId" element={<Payment />} />
|
<Route path="/payment/:orderId" element={<Payment />} />
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const confirmPayment = (orderId) => api.post(`/orders/${orderId}/confirm_
|
|||||||
export const getServices = () => api.get('/services/');
|
export const getServices = () => api.get('/services/');
|
||||||
export const getServiceDetail = (id) => api.get(`/services/${id}/`);
|
export const getServiceDetail = (id) => api.get(`/services/${id}/`);
|
||||||
export const createServiceOrder = (data) => api.post('/service-orders/', data);
|
export const createServiceOrder = (data) => api.post('/service-orders/', data);
|
||||||
export const getVBCourses = () => api.get('/courses/');
|
export const getVCCourses = () => api.get('/courses/');
|
||||||
|
export const getVCCourseDetail = (id) => api.get(`/courses/${id}/`);
|
||||||
|
export const enrollCourse = (data) => api.post('/course-enrollments/', data);
|
||||||
|
|
||||||
export const sendSms = (data) => api.post('/auth/send-sms/', data);
|
export const sendSms = (data) => api.post('/auth/send-sms/', data);
|
||||||
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const Layout = ({ children }) => {
|
|||||||
{
|
{
|
||||||
key: '/courses',
|
key: '/courses',
|
||||||
icon: <EyeOutlined />,
|
icon: <EyeOutlined />,
|
||||||
label: 'VB 课程',
|
label: 'VC 课程',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/my-orders',
|
key: '/my-orders',
|
||||||
|
|||||||
285
frontend/src/pages/VCCourseDetail.jsx
Normal file
285
frontend/src/pages/VCCourseDetail.jsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, FormOutlined } from '@ant-design/icons';
|
||||||
|
import { getVCCourseDetail, createOrder } from '../api';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const VCCourseDetail = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [course, setCourse] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 优先从 URL 获取,如果没有则从 localStorage 获取
|
||||||
|
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getVCCourseDetail(id);
|
||||||
|
setCourse(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch course detail:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDetail();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleEnroll = async (values) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const orderData = {
|
||||||
|
course: course.id,
|
||||||
|
customer_name: values.customer_name,
|
||||||
|
phone_number: values.phone_number,
|
||||||
|
ref_code: refCode,
|
||||||
|
quantity: 1,
|
||||||
|
// 将其他信息放入收货地址字段中
|
||||||
|
shipping_address: `[课程报名] 微信号: ${values.wechat_id || '无'}, 邮箱: ${values.email || '无'}, 备注: ${values.message || '无'}`
|
||||||
|
};
|
||||||
|
|
||||||
|
await createOrder(orderData);
|
||||||
|
message.success('报名咨询已提交,我们的课程顾问将尽快与您联系!');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error('提交失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
|
<Spin size="large" tip="Loading..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
|
<Empty description="Course not found" />
|
||||||
|
<Button type="primary" onClick={() => navigate('/courses')} style={{ marginTop: 20 }}>
|
||||||
|
Return to Courses
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px 0', minHeight: '80vh' }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
style={{ color: '#fff', marginBottom: 20 }}
|
||||||
|
onClick={() => navigate('/courses')}
|
||||||
|
>
|
||||||
|
返回课程列表
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[40, 40]}>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<div style={{ textAlign: 'left', marginBottom: 40 }}>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginBottom: 10 }}>
|
||||||
|
{course.tag && <Tag color="volcano">{course.tag}</Tag>}
|
||||||
|
<Tag color={course.course_type === 'hardware' ? 'purple' : 'cyan'}>
|
||||||
|
{course.course_type_display || (course.course_type === 'hardware' ? '硬件课程' : '软件课程')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<Title level={1} style={{ color: '#fff', marginTop: 0 }}>
|
||||||
|
{course.title}
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: '#888', fontSize: 18 }}>
|
||||||
|
{course.description}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 30,
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||||
|
}}>
|
||||||
|
<Title level={4} style={{ color: '#fff', marginBottom: 20, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 4, height: 18, background: '#00f0ff', marginRight: 10, borderRadius: 2 }} />
|
||||||
|
课程信息
|
||||||
|
</Title>
|
||||||
|
<Descriptions
|
||||||
|
column={{ xs: 1, sm: 2, md: 3 }}
|
||||||
|
labelStyle={{ color: '#888', fontWeight: 'normal' }}
|
||||||
|
contentStyle={{ color: '#fff', fontWeight: '500' }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><UserOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 讲师</span>}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{course.instructor_avatar_url && (
|
||||||
|
<img src={course.instructor_avatar_url} alt="avatar" style={{ width: 24, height: 24, borderRadius: '50%', marginRight: 8, objectFit: 'cover' }} />
|
||||||
|
)}
|
||||||
|
<span>{course.instructor}</span>
|
||||||
|
{course.instructor_title && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'rgba(0, 240, 255, 0.1)',
|
||||||
|
color: '#00f0ff',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 8
|
||||||
|
}}>
|
||||||
|
{course.instructor_title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><ClockCircleOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 时长</span>}>
|
||||||
|
{course.duration}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><BookOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 课时</span>}>
|
||||||
|
{course.lesson_count} 课时
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{/* 讲师简介 */}
|
||||||
|
{course.instructor_desc && (
|
||||||
|
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid rgba(255,255,255,0.05)', color: '#aaa', fontSize: 14 }}>
|
||||||
|
<span style={{ color: '#666', marginRight: 10 }}>讲师简介:</span>
|
||||||
|
{course.instructor_desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 课程详细内容区域 */}
|
||||||
|
{course.content && (
|
||||||
|
<div style={{ marginTop: 40 }}>
|
||||||
|
<Title level={3} style={{ color: '#fff', marginBottom: 20 }}>课程大纲与详情</Title>
|
||||||
|
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px', whiteSpace: 'pre-line' }}>
|
||||||
|
{course.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.display_detail_image ? (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '40px auto 0',
|
||||||
|
background: '#111',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: `0 10px 40px rgba(0, 240, 255, 0.1)`,
|
||||||
|
border: `1px solid rgba(0, 240, 255, 0.2)`
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={course.display_detail_image}
|
||||||
|
alt={course.title}
|
||||||
|
style={{ width: '100%', display: 'block', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ position: 'sticky', top: 100 }}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1f1f1f',
|
||||||
|
padding: 30,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid rgba(0, 240, 255, 0.2)`,
|
||||||
|
boxShadow: `0 0 20px rgba(0, 240, 255, 0.05)`
|
||||||
|
}}>
|
||||||
|
<Title level={3} style={{ color: '#fff', marginTop: 0 }}>报名咨询</Title>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 20 }}>
|
||||||
|
{parseFloat(course.price) > 0 ? (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>¥{course.price}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>免费咨询</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<FormOutlined />}
|
||||||
|
style={{
|
||||||
|
height: 50,
|
||||||
|
background: '#00f0ff',
|
||||||
|
borderColor: '#00f0ff',
|
||||||
|
color: '#000',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
>
|
||||||
|
立即报名 / 咨询
|
||||||
|
</Button>
|
||||||
|
<p style={{ color: '#666', marginTop: 15, fontSize: 12, textAlign: 'center' }}>
|
||||||
|
* 提交后我们的顾问将尽快与您联系确认
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Enroll Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`报名/咨询 - ${course.title}`}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: 20, color: '#666' }}>请填写您的联系方式,我们将为您安排课程顾问。</p>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleEnroll}
|
||||||
|
>
|
||||||
|
<Form.Item label="您的姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||||
|
<Input placeholder="例如:李同学" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
|
||||||
|
<Input placeholder="13800000000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="微信号" name="wechat_id">
|
||||||
|
<Input placeholder="选填,方便微信沟通" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="电子邮箱" name="email" rules={[{ type: 'email' }]}>
|
||||||
|
<Input placeholder="example@email.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注/留言" name="message">
|
||||||
|
<Input.TextArea rows={4} placeholder="您想了解的任何问题..." />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
|
||||||
|
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={submitting}>提交报名</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VCCourseDetail;
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Button, Typography, Spin, Row, Col, Empty, Tag } from 'antd';
|
import { Button, Typography, Spin, Row, Col, Empty, Tag } from 'antd';
|
||||||
import { ReadOutlined, ClockCircleOutlined, UserOutlined, BookOutlined } from '@ant-design/icons';
|
import { ReadOutlined, ClockCircleOutlined, UserOutlined, BookOutlined } from '@ant-design/icons';
|
||||||
import { getVBCourses } from '../api';
|
import { getVCCourses } from '../api';
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography;
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
const VBCourses = () => {
|
const VCCourses = () => {
|
||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getVBCourses();
|
const res = await getVCCourses();
|
||||||
setCourses(res.data);
|
setCourses(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch VB Courses:", error);
|
console.error("Failed to fetch VC Courses:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -30,10 +32,10 @@ const VBCourses = () => {
|
|||||||
<div style={{ padding: '40px 0', minHeight: '80vh', position: 'relative' }}>
|
<div style={{ padding: '40px 0', minHeight: '80vh', position: 'relative' }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 60, position: 'relative', zIndex: 2 }}>
|
<div style={{ textAlign: 'center', marginBottom: 60, position: 'relative', zIndex: 2 }}>
|
||||||
<Title level={1} style={{ color: '#fff', letterSpacing: 4 }}>
|
<Title level={1} style={{ color: '#fff', letterSpacing: 4 }}>
|
||||||
VB <span style={{ color: '#00f0ff' }}>CODING COURSES</span>
|
VC <span style={{ color: '#00f0ff' }}>CODING COURSES</span>
|
||||||
</Title>
|
</Title>
|
||||||
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
|
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
|
||||||
探索 Vibe Coding 软件与硬件课程,开启您的编程之旅。
|
探索 VB Coding 软件与硬件课程,开启您的编程之旅。
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ const VBCourses = () => {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onClick={() => navigate(`/courses/${item.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(255,255,255,0.05)',
|
background: 'rgba(255,255,255,0.05)',
|
||||||
@@ -122,4 +126,4 @@ const VBCourses = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VBCourses;
|
export default VCCourses;
|
||||||
@@ -1,10 +1,35 @@
|
|||||||
.page-container {
|
.page-container {
|
||||||
padding: 20px;
|
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 100px); /* 留出底部栏高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 420px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
padding: 30px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 30px 30px 0 0;
|
||||||
|
margin-top: -30px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 30px;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
@@ -13,62 +38,219 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info {
|
.tags-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
gap: 16px;
|
||||||
gap: 20px;
|
margin-bottom: 20px;
|
||||||
margin-bottom: 30px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #aaa;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
&.highlight {
|
||||||
background: rgba(0, 240, 255, 0.2);
|
background: rgba(0, 240, 255, 0.2);
|
||||||
color: #00f0ff;
|
color: #00f0ff;
|
||||||
padding: 6px 16px;
|
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||||
border-radius: 4px;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #00f0ff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 50px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 6px;
|
||||||
|
background: #00f0ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructor-section {
|
||||||
|
.instructor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #111;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
color: #666;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
border: 1px solid #00f0ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
color: #888;
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 20px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructor-info {
|
||||||
|
flex: 1;
|
||||||
|
.name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.title-tag {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #000;
|
||||||
|
background: #00f0ff;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
.desc {
|
.desc {
|
||||||
color: #aaa;
|
color: #888;
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
margin-bottom: 40px;
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #111;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
.grid-item {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #666;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-placeholder {
|
.value {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-text {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-images {
|
||||||
|
.detail-long-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 500px;
|
border-radius: 16px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 2px dashed #333;
|
border: 2px dashed #333;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 80px;
|
|
||||||
color: #444;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 28px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-launch {
|
.bottom-bar {
|
||||||
margin-top: 60px;
|
height: 120px;
|
||||||
background: #00f0ff;
|
background: #111;
|
||||||
color: #000;
|
border-top: 1px solid #222;
|
||||||
font-weight: bold;
|
display: flex;
|
||||||
border-radius: 45px;
|
align-items: center;
|
||||||
|
padding: 0 30px;
|
||||||
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
|
||||||
|
.price-container {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
color: #00f0ff;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-buy {
|
||||||
|
width: 240px;
|
||||||
|
height: 80px;
|
||||||
|
line-height: 80px;
|
||||||
|
background: linear-gradient(90deg, #00f0ff, #0099ff);
|
||||||
|
color: #000;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 40px;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, Button, Image } from '@tarojs/components'
|
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro, { useLoad } from '@tarojs/taro'
|
import Taro, { useLoad } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getVBCourseDetail } from '../../api'
|
import { getVBCourseDetail } from '../../api'
|
||||||
@@ -31,9 +31,9 @@ export default function CourseDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLaunch = () => {
|
const handleLaunch = () => {
|
||||||
Taro.showToast({
|
if (!detail) return
|
||||||
title: '课程内容准备中',
|
Taro.navigateTo({
|
||||||
icon: 'none'
|
url: `/pages/order/checkout?id=${detail.id}&type=course`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,29 +42,89 @@ export default function CourseDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='page-container'>
|
<View className='page-container'>
|
||||||
<Text className='title'>{detail.title}</Text>
|
<ScrollView scrollY className='scroll-content'>
|
||||||
|
{/* 封面图 */}
|
||||||
|
{detail.cover_image_url && (
|
||||||
|
<Image src={detail.cover_image_url} className='cover-image' mode='aspectFill' />
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='meta-info'>
|
<View className='content-wrapper'>
|
||||||
<Text className='tag'>{typeMap[detail.course_type] || '软件课程'}</Text>
|
{/* 标题区 */}
|
||||||
<Text className='info'>讲师: {detail.instructor}</Text>
|
<View className='header-section'>
|
||||||
<Text className='info'>时长: {detail.duration}</Text>
|
<Text className='title'>{detail.title}</Text>
|
||||||
<Text className='info'>课时: {detail.lesson_count}</Text>
|
<View className='tags-row'>
|
||||||
|
<Text className='tag'>{typeMap[detail.course_type] || 'VB课程'}</Text>
|
||||||
|
{detail.tag && <Text className='tag highlight'>{detail.tag}</Text>}
|
||||||
|
</View>
|
||||||
|
<Text className='price'>¥{detail.price}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className='desc'>{detail.description}</Text>
|
{/* 讲师信息 */}
|
||||||
|
<View className='section instructor-section'>
|
||||||
<View className='course-placeholder'>
|
<Text className='section-title'>讲师介绍</Text>
|
||||||
{detail.display_detail_image ? (
|
<View className='instructor-row'>
|
||||||
<Image src={detail.display_detail_image} style={{ width: '100%', height: '100%', borderRadius: '16px' }} mode='widthFix' />
|
{detail.instructor_avatar_url ? (
|
||||||
|
<Image src={detail.instructor_avatar_url} className='avatar' mode='aspectFill' />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<View className='avatar-placeholder'>
|
||||||
<Text className='icon'>📚</Text>
|
<Text>讲师</Text>
|
||||||
<Text className='text'>课程大纲与视频内容加载区域</Text>
|
</View>
|
||||||
</>
|
)}
|
||||||
|
<View className='instructor-info'>
|
||||||
|
<Text className='name'>{detail.instructor} <Text className='title-tag'>{detail.instructor_title}</Text></Text>
|
||||||
|
<Text className='desc'>{detail.instructor_desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 课程信息 */}
|
||||||
|
<View className='section info-grid'>
|
||||||
|
<View className='grid-item'>
|
||||||
|
<Text className='label'>时长</Text>
|
||||||
|
<Text className='value'>{detail.duration}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='grid-item'>
|
||||||
|
<Text className='label'>课时</Text>
|
||||||
|
<Text className='value'>{detail.lesson_count}节</Text>
|
||||||
|
</View>
|
||||||
|
<View className='grid-item'>
|
||||||
|
<Text className='label'>难度</Text>
|
||||||
|
<Text className='value'>中级</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 课程简介 */}
|
||||||
|
<View className='section'>
|
||||||
|
<Text className='section-title'>课程简介</Text>
|
||||||
|
<Text className='desc-text'>{detail.description}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 详情长图 */}
|
||||||
|
<View className='section detail-images'>
|
||||||
|
<Text className='section-title'>课程详情</Text>
|
||||||
|
{detail.display_detail_image || detail.detail_image_url ? (
|
||||||
|
<Image
|
||||||
|
src={detail.detail_image_url || detail.display_detail_image}
|
||||||
|
className='detail-long-image'
|
||||||
|
mode='widthFix'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='placeholder-box'>
|
||||||
|
<Text>暂无详情长图</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<Button className='btn-launch' onClick={handleLaunch}>开始学习</Button>
|
{/* 底部栏 */}
|
||||||
|
<View className='bottom-bar'>
|
||||||
|
<View className='price-container'>
|
||||||
|
<Text className='label'>总价:</Text>
|
||||||
|
<Text className='amount'>¥{detail.price}</Text>
|
||||||
|
</View>
|
||||||
|
<Button className='btn-buy' onClick={handleLaunch}>立即报名</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function CourseIndex() {
|
|||||||
<View className='content'>
|
<View className='content'>
|
||||||
<Text className='item-title'>{item.title}</Text>
|
<Text className='item-title'>{item.title}</Text>
|
||||||
<Text className='item-desc'>{item.description}</Text>
|
<Text className='item-desc'>{item.description}</Text>
|
||||||
<Button className='btn-start'>开始学习</Button>
|
<Button className='btn-start' onClick={(e) => { e.stopPropagation(); goDetail(item.id) }}>报名课程</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
|
||||||
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
import Taro, { useRouter, useLoad } from '@tarojs/taro'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { getConfigDetail, createOrder } from '../../api'
|
import { getConfigDetail, createOrder, getVBCourseDetail } from '../../api'
|
||||||
import { getSelectedItems, removeItem } from '../../utils/cart'
|
import { getSelectedItems, removeItem } from '../../utils/cart'
|
||||||
import './checkout.scss'
|
import './checkout.scss'
|
||||||
|
|
||||||
@@ -34,7 +34,19 @@ export default function Checkout() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
} else if (params.id) {
|
} else if (params.id) {
|
||||||
try {
|
try {
|
||||||
const res = await getConfigDetail(params.id)
|
let res: any = null
|
||||||
|
if (params.type === 'course') {
|
||||||
|
res = await getVBCourseDetail(Number(params.id))
|
||||||
|
setItems([{
|
||||||
|
id: res.id,
|
||||||
|
name: res.title,
|
||||||
|
price: res.price,
|
||||||
|
image: res.cover_image_url || res.detail_image_url,
|
||||||
|
quantity: 1,
|
||||||
|
description: res.description
|
||||||
|
}])
|
||||||
|
} else {
|
||||||
|
res = await getConfigDetail(params.id)
|
||||||
setItems([{
|
setItems([{
|
||||||
id: res.id,
|
id: res.id,
|
||||||
name: res.name,
|
name: res.name,
|
||||||
@@ -43,6 +55,7 @@ export default function Checkout() {
|
|||||||
quantity: Number(params.quantity) || 1,
|
quantity: Number(params.quantity) || 1,
|
||||||
description: res.description
|
description: res.description
|
||||||
}])
|
}])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
Taro.showToast({ title: '商品加载失败', icon: 'none' })
|
Taro.showToast({ title: '商品加载失败', icon: 'none' })
|
||||||
@@ -93,7 +106,8 @@ export default function Checkout() {
|
|||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
customer_name: address.userName,
|
customer_name: address.userName,
|
||||||
phone_number: address.telNumber,
|
phone_number: address.telNumber,
|
||||||
shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`
|
shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`,
|
||||||
|
type: params.type || 'config'
|
||||||
}
|
}
|
||||||
return createOrder(orderData)
|
return createOrder(orderData)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user