diff --git a/.gitignore b/.gitignore index 0e8c4e2..f5d62d5 100644 --- a/.gitignore +++ b/.gitignore @@ -93,7 +93,7 @@ ehthumbs.db *.3g2 *.asf *.rm -*.rmvb +*.rmVB *.vob *.mpg *.mpeg diff --git a/README.md b/README.md deleted file mode 100644 index 35048e2..0000000 --- a/README.md +++ /dev/null @@ -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/) - ---- - -**⭐ 如果这个项目对您有帮助,请给我们一个星标!** \ No newline at end of file diff --git a/backend/check_urls.py b/backend/check_urls.py index 6dd3a96..19ca943 100644 --- a/backend/check_urls.py +++ b/backend/check_urls.py @@ -12,7 +12,7 @@ links = [ "admin:shop_distributor_changelist", "admin:shop_esp32config_changelist", "admin:shop_service_changelist", - "admin:shop_vbcourse_changelist", + "admin:shop_VBcourse_changelist", "admin:shop_order_changelist", "admin:shop_serviceorder_changelist", "admin:shop_withdrawal_changelist", diff --git a/backend/config/__pycache__/settings.cpython-312.pyc b/backend/config/__pycache__/settings.cpython-312.pyc index c8aed22..7392c16 100644 Binary files a/backend/config/__pycache__/settings.cpython-312.pyc and b/backend/config/__pycache__/settings.cpython-312.pyc differ diff --git a/backend/config/__pycache__/settings.cpython-313.pyc b/backend/config/__pycache__/settings.cpython-313.pyc index b372ee5..f89d647 100644 Binary files a/backend/config/__pycache__/settings.cpython-313.pyc and b/backend/config/__pycache__/settings.cpython-313.pyc differ diff --git a/backend/config/settings.py b/backend/config/settings.py index 215dbd0..634dffd 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -232,9 +232,9 @@ UNFOLD = { "link": reverse_lazy("admin:shop_service_changelist"), }, { - "title": "VB课程", + "title": "VC课程", "icon": "school", - "link": reverse_lazy("admin:shop_vbcourse_changelist"), + "link": reverse_lazy("admin:shop_vccourse_changelist"), }, ], }, diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 index 0cf1b4e..8be5396 100644 Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ diff --git a/backend/shop/__pycache__/admin.cpython-312.pyc b/backend/shop/__pycache__/admin.cpython-312.pyc index db1d0fe..8285aa9 100644 Binary files a/backend/shop/__pycache__/admin.cpython-312.pyc and b/backend/shop/__pycache__/admin.cpython-312.pyc differ diff --git a/backend/shop/__pycache__/admin.cpython-313.pyc b/backend/shop/__pycache__/admin.cpython-313.pyc index b314ad6..b311a91 100644 Binary files a/backend/shop/__pycache__/admin.cpython-313.pyc and b/backend/shop/__pycache__/admin.cpython-313.pyc differ diff --git a/backend/shop/__pycache__/models.cpython-312.pyc b/backend/shop/__pycache__/models.cpython-312.pyc index 1961d41..01bc3a1 100644 Binary files a/backend/shop/__pycache__/models.cpython-312.pyc and b/backend/shop/__pycache__/models.cpython-312.pyc differ diff --git a/backend/shop/__pycache__/models.cpython-313.pyc b/backend/shop/__pycache__/models.cpython-313.pyc index 4383773..0218948 100644 Binary files a/backend/shop/__pycache__/models.cpython-313.pyc and b/backend/shop/__pycache__/models.cpython-313.pyc differ diff --git a/backend/shop/__pycache__/serializers.cpython-312.pyc b/backend/shop/__pycache__/serializers.cpython-312.pyc index 8cf1e44..6140d80 100644 Binary files a/backend/shop/__pycache__/serializers.cpython-312.pyc and b/backend/shop/__pycache__/serializers.cpython-312.pyc differ diff --git a/backend/shop/__pycache__/serializers.cpython-313.pyc b/backend/shop/__pycache__/serializers.cpython-313.pyc index 0164209..76f8aff 100644 Binary files a/backend/shop/__pycache__/serializers.cpython-313.pyc and b/backend/shop/__pycache__/serializers.cpython-313.pyc differ diff --git a/backend/shop/__pycache__/urls.cpython-312.pyc b/backend/shop/__pycache__/urls.cpython-312.pyc index f690115..202d13c 100644 Binary files a/backend/shop/__pycache__/urls.cpython-312.pyc and b/backend/shop/__pycache__/urls.cpython-312.pyc differ diff --git a/backend/shop/__pycache__/urls.cpython-313.pyc b/backend/shop/__pycache__/urls.cpython-313.pyc index a251174..dc0bc45 100644 Binary files a/backend/shop/__pycache__/urls.cpython-313.pyc and b/backend/shop/__pycache__/urls.cpython-313.pyc differ diff --git a/backend/shop/__pycache__/views.cpython-312.pyc b/backend/shop/__pycache__/views.cpython-312.pyc index db1c7c7..bfb9b97 100644 Binary files a/backend/shop/__pycache__/views.cpython-312.pyc and b/backend/shop/__pycache__/views.cpython-312.pyc differ diff --git a/backend/shop/__pycache__/views.cpython-313.pyc b/backend/shop/__pycache__/views.cpython-313.pyc index 8cd5bca..4eee818 100644 Binary files a/backend/shop/__pycache__/views.cpython-313.pyc and b/backend/shop/__pycache__/views.cpython-313.pyc differ diff --git a/backend/shop/admin.py b/backend/shop/admin.py index 81f2527..9117603 100644 --- a/backend/shop/admin.py +++ b/backend/shop/admin.py @@ -4,7 +4,7 @@ from django.db.models import Sum from django import forms from unfold.admin import ModelAdmin, TabularInline 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 from io import BytesIO import base64 @@ -83,7 +83,7 @@ class ESP32ConfigAdmin(ModelAdmin): inlines = [ProductFeatureInline] fieldsets = ( ('基本信息', { - 'fields': ('name', 'price', 'stock', 'description') + 'fields': ('name', 'price', 'stock', 'commission_rate', 'description') }), ('硬件参数', { 'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone') @@ -141,17 +141,21 @@ class ServiceOrderAdmin(ModelAdmin): }), ) -@admin.register(VBCourse) -class VBCourseAdmin(ModelAdmin): - list_display = ('title', 'course_type', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at') +@admin.register(VCCourse) +class VCCourseAdmin(ModelAdmin): + list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at') search_fields = ('title', 'description', 'instructor', 'tag') list_filter = ('course_type', 'instructor', 'tag') 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'), @@ -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) class SalespersonAdmin(ModelAdmin): list_display = ('name', 'code', 'total_sales', 'view_promotion_url') @@ -240,14 +262,14 @@ class SalespersonAdmin(ModelAdmin): @admin.register(CommissionLog) class CommissionLogAdmin(ModelAdmin): - list_display = ('id', 'salesperson', 'amount', 'level', 'status', 'created_at') - list_filter = ('status', 'level', 'salesperson', 'created_at') - search_fields = ('salesperson__name', 'order__id') + list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at') + list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at') + search_fields = ('salesperson__name', 'distributor__user__nickname', 'order__id') readonly_fields = ('amount', 'level', 'created_at') fieldsets = ( ('基本信息', { - 'fields': ('salesperson', 'order', 'amount', 'level') + 'fields': ('salesperson', 'distributor', 'order', 'amount', 'level') }), ('状态管理', { 'fields': ('status', 'created_at') @@ -256,23 +278,31 @@ class CommissionLogAdmin(ModelAdmin): @admin.register(Order) class OrderAdmin(ModelAdmin): - list_display = ('id', 'customer_name', 'config', 'total_price', 'status', 'courier_name', 'tracking_number', 'salesperson', 'created_at') - list_filter = ('status', 'salesperson', 'created_at') + list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at') + list_filter = ('status', 'salesperson', 'distributor', 'created_at') search_fields = ('id', 'customer_name', 'phone_number', '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 = ( ('订单信息', { - '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': ('salesperson',) + 'fields': ('salesperson', 'distributor') }), ('支付信息', { 'fields': ('wechat_trade_no',) diff --git a/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py b/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py new file mode 100644 index 0000000..c452b7a --- /dev/null +++ b/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py @@ -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='获佣销售员'), + ), + ] diff --git a/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py b/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py new file mode 100644 index 0000000..0c3faab --- /dev/null +++ b/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py @@ -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': '课程报名管理', + }, + ), + ] diff --git a/backend/shop/migrations/0023_order_course_alter_order_config.py b/backend/shop/migrations/0023_order_course_alter_order_config.py new file mode 100644 index 0000000..48ff8a3 --- /dev/null +++ b/backend/shop/migrations/0023_order_course_alter_order_config.py @@ -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='所选配置'), + ), + ] diff --git a/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py b/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py new file mode 100644 index 0000000..1be9c01 --- /dev/null +++ b/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py @@ -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='讲师头衔'), + ), + ] diff --git a/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py b/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py new file mode 100644 index 0000000..9188f37 --- /dev/null +++ b/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py @@ -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', + ), + ] diff --git a/backend/shop/models.py b/backend/shop/models.py index 2623222..fa31b82 100644 --- a/backend/shop/models.py +++ b/backend/shop/models.py @@ -165,7 +165,8 @@ class CommissionLog(models.Model): ) 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="佣金金额") level = models.IntegerField(default=1, verbose_name="分销层级", help_text="1: 直接销售, 2: 二级分销") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") @@ -219,13 +220,15 @@ class Order(models.Model): ('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="数量") 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="订单状态") # 销售归属 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') @@ -312,9 +315,9 @@ class ServiceOrder(models.Model): verbose_name_plural = "服务订单列表" -class VBCourse(models.Model): +class VCCourse(models.Model): """ - VB Coding 课程模型 + VC (VB Coding) 课程模型 """ COURSE_TYPE_CHOICES = ( ('software', '软件课程'), @@ -327,10 +330,17 @@ class VBCourse(models.Model): 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分钟") 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="例如: 热门, 推荐, 进阶") + 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_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)") @@ -343,5 +353,40 @@ class VBCourse(models.Model): return self.title class Meta: - verbose_name = "VB课程" - verbose_name_plural = "VB课程管理" + verbose_name = "VC课程" + 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 = "课程报名管理" diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py index 8c4d7e8..3e42a00 100644 --- a/backend/shop/serializers.py +++ b/backend/shop/serializers.py @@ -1,5 +1,23 @@ 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 Meta: @@ -69,6 +87,37 @@ class ServiceSerializer(serializers.ModelSerializer): return obj.detail_image.url 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): """ AI服务订单序列化器 @@ -101,16 +150,16 @@ class ServiceOrderSerializer(serializers.ModelSerializer): return super().create(validated_data) -class VBCourseSerializer(serializers.ModelSerializer): +class VCCourseSerializer(serializers.ModelSerializer): """ - VB课程序列化器 + VC课程序列化器 """ display_cover_image = serializers.SerializerMethodField() display_detail_image = serializers.SerializerMethodField() course_type_display = serializers.CharField(source='get_course_type_display', read_only=True) class Meta: - model = VBCourse + model = VCCourse fields = '__all__' 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) + course_title = serializers.CharField(source='course.title', read_only=True) config_image = serializers.SerializerMethodField() salesperson_name = serializers.CharField(source='salesperson.name', read_only=True) salesperson_code = serializers.CharField(source='salesperson.code', read_only=True) @@ -159,41 +209,76 @@ class OrderSerializer(serializers.ModelSerializer): class Meta: 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'] read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at'] extra_kwargs = { 'customer_name': {'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): - if obj.config.static_image_url: - return obj.config.static_image_url - if obj.config.detail_image_url: - return obj.config.detail_image_url - if obj.config.detail_image: - return obj.config.detail_image.url + if obj.config: + if obj.config.static_image_url: + return obj.config.static_image_url + if obj.config.detail_image_url: + return obj.config.detail_image_url + if obj.config.detail_image: + 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 def create(self, validated_data): """ - 重写创建方法,自动计算总价并关联销售员 + 重写创建方法,自动计算总价并关联销售员/分销员 """ config = validated_data.get('config') + course = validated_data.get('course') quantity = validated_data.get('quantity', 1) ref_code = validated_data.pop('ref_code', None) - validated_data['total_price'] = config.price * quantity - - # 尝试关联销售员 + if config: + validated_data['total_price'] = config.price * quantity + elif course: + validated_data['total_price'] = course.price * quantity + + # 尝试关联销售员或分销员 if ref_code: + # 1. 尝试查找旧版销售员 try: salesperson = Salesperson.objects.get(code=ref_code) validated_data['salesperson'] = salesperson except Salesperson.DoesNotExist: - # 如果找不到对应的销售员,忽略该推广码,仍继续创建订单(算作自然流量) + pass + + # 2. 尝试查找新版分销员 + try: + distributor = Distributor.objects.get(invite_code=ref_code) + validated_data['distributor'] = distributor + except Distributor.DoesNotExist: pass return super().create(validated_data) diff --git a/backend/shop/urls.py b/backend/shop/urls.py index 3464a6d..bc81dae 100644 --- a/backend/shop/urls.py +++ b/backend/shop/urls.py @@ -2,15 +2,17 @@ from django.urls import path, include, re_path from rest_framework.routers import DefaultRouter from .views import ( ESP32ConfigViewSet, OrderViewSet, order_check_view, - ServiceViewSet, VBCourseViewSet, ServiceOrderViewSet, - payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet + ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet, + payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet, + CourseEnrollmentViewSet ) router = DefaultRouter() router.register(r'configs', ESP32ConfigViewSet) router.register(r'orders', OrderViewSet) 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'distributor', DistributorViewSet, basename='distributor') diff --git a/backend/shop/views.py b/backend/shop/views.py index 3982f2e..b9d6dd8 100644 --- a/backend/shop/views.py +++ b/backend/shop/views.py @@ -5,8 +5,8 @@ from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.http import HttpResponse 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 .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VBCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer +from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment +from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.contrib.auth.models import User from wechatpayv3 import WeChatPay, WeChatPayType @@ -215,6 +215,7 @@ def pay(request): # 1. 获取并验证请求参数 good_id = request.data.get('goodid') + order_type = request.data.get('type', 'config') # 默认为 config quantity = int(request.data.get('quantity', 1)) customer_name = request.data.get('customer_name') phone_number = request.data.get('phone_number') @@ -237,15 +238,23 @@ def pay(request): return Response({'error': f'支付配置错误: {error_msg}'}, status=status.HTTP_400_BAD_REQUEST) # 3. 查找商品和销售员,创建订单 - try: - product = ESP32Config.objects.get(id=good_id) - except ESP32Config.DoesNotExist: - print(f"商品不存在: {good_id}") - return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND) + 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: + product = ESP32Config.objects.get(id=good_id) + except ESP32Config.DoesNotExist: + print(f"商品不存在: {good_id}") + return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND) - # 检查库存 - if product.stock < quantity: - return Response({'error': f'库存不足,仅剩 {product.stock} 件'}, status=status.HTTP_400_BAD_REQUEST) + # 检查库存 (仅针对硬件) + if product.stock < quantity: + return Response({'error': f'库存不足,仅剩 {product.stock} 件'}, status=status.HTTP_400_BAD_REQUEST) salesperson = None if ref_code: @@ -255,24 +264,34 @@ def pay(request): total_price = product.price * quantity amount_in_cents = int(total_price * 100) - order = Order.objects.create( - config=product, - quantity=quantity, - total_price=total_price, - customer_name=customer_name, - phone_number=phone_number, - shipping_address=shipping_address, - salesperson=salesperson, - status='pending' - ) + order_kwargs = { + 'quantity': quantity, + 'total_price': total_price, + 'customer_name': customer_name, + 'phone_number': phone_number, + 'shipping_address': shipping_address, + 'salesperson': salesperson, + 'status': 'pending' + } + + if order_type == 'course': + order_kwargs['course'] = product + else: + order_kwargs['config'] = product - # 扣减库存 - product.stock -= quantity - product.save() + order = Order.objects.create(**order_kwargs) + + # 扣减库存 (仅针对硬件) + if order_type != 'course': + product.stock -= quantity + product.save() # 4. 调用微信支付接口 out_trade_no = f"PAY{order.id}T{int(time.time())}" - description = f"购买 {product.name} x {quantity}" + if order_type == 'course': + description = f"报名 {product.title}" + else: + description = f"购买 {product.name} x {quantity}" # 保存商户订单号到数据库,方便后续查询 order.out_trade_no = out_trade_no @@ -459,13 +478,19 @@ def payment_finish(request): order.save() print(f"订单 {order.id} 状态已更新") - # 计算佣金 + # 计算佣金 (旧版销售员系统) try: salesperson = order.salesperson if salesperson: # 1. 计算直接佣金 (一级) # 优先级: 产品独立分润比例 > 销售员个人分润比例 - rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate + rate_1 = 0 + if order.config: + 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 if amount_1 > 0: @@ -476,7 +501,7 @@ def payment_finish(request): level=1, status='pending' ) - print(f"生成一级佣金: {salesperson.name} - {amount_1}") + print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}") # 2. 计算上级佣金 (二级) parent = salesperson.parent @@ -492,9 +517,61 @@ def payment_finish(request): level=2, 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: print(f"佣金计算失败: {str(e)}") + import traceback + traceback.print_exc() except Exception as e: print(f"订单更新失败: {str(e)}") @@ -508,15 +585,22 @@ def payment_finish(request): return HttpResponse(str(e), status=500) @extend_schema_view( - list=extend_schema(summary="获取VB课程列表", description="获取所有可用的VB课程"), - retrieve=extend_schema(summary="获取VB课程详情", description="获取指定VB课程的详细信息") + list=extend_schema(summary="获取VC课程列表", description="获取所有可用的VC课程"), + 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') - serializer_class = VBCourseSerializer + queryset = VCCourse.objects.all().order_by('-created_at') + serializer_class = VCCourseSerializer + +class CourseEnrollmentViewSet(viewsets.ModelViewSet): + """ + 课程报名管理 + """ + queryset = CourseEnrollment.objects.all().order_by('-created_at') + serializer_class = CourseEnrollmentSerializer def order_check_view(request): """ @@ -989,3 +1073,64 @@ class DistributorViewSet(viewsets.GenericViewSet): 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) + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f44e2cf..e8bb771 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,7 +6,8 @@ import ProductDetail from './pages/ProductDetail'; import Payment from './pages/Payment'; import AIServices from './pages/AIServices'; 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 'antd/dist/reset.css'; import './App.css'; @@ -19,7 +20,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index f01ac76..2603283 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -19,7 +19,9 @@ export const confirmPayment = (orderId) => api.post(`/orders/${orderId}/confirm_ export const getServices = () => api.get('/services/'); export const getServiceDetail = (id) => api.get(`/services/${id}/`); 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 queryMyOrders = (data) => api.post('/orders/my_orders/', data); diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 6adaa09..268a24c 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -36,7 +36,7 @@ const Layout = ({ children }) => { { key: '/courses', icon: , - label: 'VB 课程', + label: 'VC 课程', }, { key: '/my-orders', diff --git a/frontend/src/pages/VCCourseDetail.jsx b/frontend/src/pages/VCCourseDetail.jsx new file mode 100644 index 0000000..95b366b --- /dev/null +++ b/frontend/src/pages/VCCourseDetail.jsx @@ -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 ( +
+ +
+ ); + } + + if (!course) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + + + +
+
+ {course.tag && {course.tag}} + + {course.course_type_display || (course.course_type === 'hardware' ? '硬件课程' : '软件课程')} + +
+ + {course.title} + + + {course.description} + + +
+ + <div style={{ width: 4, height: 18, background: '#00f0ff', marginRight: 10, borderRadius: 2 }} /> + 课程信息 + + + 讲师}> +
+ {course.instructor_avatar_url && ( + avatar + )} + {course.instructor} + {course.instructor_title && ( + + {course.instructor_title} + + )} +
+
+ 时长}> + {course.duration} + + 课时}> + {course.lesson_count} 课时 + +
+ + {/* 讲师简介 */} + {course.instructor_desc && ( +
+ 讲师简介: + {course.instructor_desc} +
+ )} +
+ + {/* 课程详细内容区域 */} + {course.content && ( +
+ 课程大纲与详情 +
+ {course.content} +
+
+ )} +
+ + {course.display_detail_image ? ( +
+ {course.title} +
+ ) : null} + + + +
+
+ 报名咨询 + +
+ {parseFloat(course.price) > 0 ? ( + <> + ¥{course.price} + + ) : ( + 免费咨询 + )} +
+ + +

+ * 提交后我们的顾问将尽快与您联系确认 +

+
+
+ +
+
+ + {/* Enroll Modal */} + setIsModalOpen(false)} + footer={null} + destroyOnHidden + > +

请填写您的联系方式,我们将为您安排课程顾问。

+
+ + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ ); +}; + +export default VCCourseDetail; \ No newline at end of file diff --git a/frontend/src/pages/VBCourses.jsx b/frontend/src/pages/VCCourses.jsx similarity index 90% rename from frontend/src/pages/VBCourses.jsx rename to frontend/src/pages/VCCourses.jsx index 910c81f..cd48a70 100644 --- a/frontend/src/pages/VBCourses.jsx +++ b/frontend/src/pages/VCCourses.jsx @@ -1,22 +1,24 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { Button, Typography, Spin, Row, Col, Empty, Tag } from 'antd'; import { ReadOutlined, ClockCircleOutlined, UserOutlined, BookOutlined } from '@ant-design/icons'; -import { getVBCourses } from '../api'; +import { getVCCourses } from '../api'; const { Title, Paragraph } = Typography; -const VBCourses = () => { +const VCCourses = () => { const [courses, setCourses] = useState([]); const [loading, setLoading] = useState(true); + const navigate = useNavigate(); useEffect(() => { const fetchCourses = async () => { try { - const res = await getVBCourses(); + const res = await getVCCourses(); setCourses(res.data); } catch (error) { - console.error("Failed to fetch VB Courses:", error); + console.error("Failed to fetch VC Courses:", error); } finally { setLoading(false); } @@ -30,10 +32,10 @@ const VBCourses = () => {
- VB <span style={{ color: '#00f0ff' }}>CODING COURSES</span> + VC <span style={{ color: '#00f0ff' }}>CODING COURSES</span> - 探索 Vibe Coding 软件与硬件课程,开启您的编程之旅。 + 探索 VB Coding 软件与硬件课程,开启您的编程之旅。
@@ -50,6 +52,8 @@ const VBCourses = () => { animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.1 }} whileHover={{ scale: 1.02 }} + onClick={() => navigate(`/courses/${item.id}`)} + style={{ cursor: 'pointer' }} >
{ ); }; -export default VBCourses; +export default VCCourses; diff --git a/miniprogram/src/pages/courses/detail.scss b/miniprogram/src/pages/courses/detail.scss index 1adc137..fb0e3e9 100644 --- a/miniprogram/src/pages/courses/detail.scss +++ b/miniprogram/src/pages/courses/detail.scss @@ -1,74 +1,256 @@ .page-container { - padding: 20px; background-color: #000; min-height: 100vh; - box-sizing: border-box; -} - -.title { - color: #fff; - font-size: 40px; - font-weight: bold; - margin-bottom: 20px; - display: block; -} - -.meta-info { display: flex; - flex-wrap: wrap; - gap: 20px; - margin-bottom: 30px; - align-items: center; + flex-direction: column; +} - .tag { - background: rgba(0, 240, 255, 0.2); - color: #00f0ff; - padding: 6px 16px; - border-radius: 4px; - font-size: 24px; - border: 1px solid #00f0ff; +.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 { + color: #fff; + font-size: 40px; + font-weight: bold; + margin-bottom: 20px; + display: block; } - .info { - color: #888; - font-size: 26px; + .tags-row { + display: flex; + gap: 16px; + margin-bottom: 20px; + + .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); + color: #00f0ff; + border: 1px solid rgba(0, 240, 255, 0.3); + } + } + } + + .price { + font-size: 48px; + color: #00f0ff; + font-weight: bold; } } -.desc { +.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; + } + } + + .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 { + color: #888; + font-size: 24px; + 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; + } + + .value { + color: #fff; + font-size: 30px; + font-weight: bold; + } + } +} + +.desc-text { color: #aaa; font-size: 28px; - margin-bottom: 40px; - display: block; + line-height: 1.6; } -.course-placeholder { - width: 100%; - height: 500px; +.detail-images { + .detail-long-image { + width: 100%; + border-radius: 16px; + display: block; + } + + .placeholder-box { + width: 100%; + height: 300px; + background: #111; + border: 2px dashed #333; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + } +} + +.bottom-bar { + height: 120px; background: #111; - border: 2px dashed #333; - border-radius: 16px; + border-top: 1px solid #222; display: flex; align-items: center; - justify-content: center; - flex-direction: column; + padding: 0 30px; + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); - .icon { - font-size: 80px; - color: #444; - margin-bottom: 20px; + .price-container { + flex: 1; + + .label { + color: #aaa; + font-size: 24px; + margin-right: 10px; + } + + .amount { + color: #00f0ff; + font-size: 40px; + font-weight: bold; + } } - .text { - color: #666; - font-size: 28px; + .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; + } } } - -.btn-launch { - margin-top: 60px; - background: #00f0ff; - color: #000; - font-weight: bold; - border-radius: 45px; -} diff --git a/miniprogram/src/pages/courses/detail.tsx b/miniprogram/src/pages/courses/detail.tsx index ab29a86..f5d5787 100644 --- a/miniprogram/src/pages/courses/detail.tsx +++ b/miniprogram/src/pages/courses/detail.tsx @@ -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 { useState } from 'react' import { getVBCourseDetail } from '../../api' @@ -31,9 +31,9 @@ export default function CourseDetail() { } const handleLaunch = () => { - Taro.showToast({ - title: '课程内容准备中', - icon: 'none' + if (!detail) return + Taro.navigateTo({ + url: `/pages/order/checkout?id=${detail.id}&type=course` }) } @@ -42,29 +42,89 @@ export default function CourseDetail() { return ( - {detail.title} - - - {typeMap[detail.course_type] || '软件课程'} - 讲师: {detail.instructor} - 时长: {detail.duration} - 课时: {detail.lesson_count} - - - {detail.description} - - - {detail.display_detail_image ? ( - - ) : ( - <> - 📚 - 课程大纲与视频内容加载区域 - + + {/* 封面图 */} + {detail.cover_image_url && ( + )} - - + + {/* 标题区 */} + + {detail.title} + + {typeMap[detail.course_type] || 'VB课程'} + {detail.tag && {detail.tag}} + + ¥{detail.price} + + + {/* 讲师信息 */} + + 讲师介绍 + + {detail.instructor_avatar_url ? ( + + ) : ( + + 讲师 + + )} + + {detail.instructor} {detail.instructor_title} + {detail.instructor_desc} + + + + + {/* 课程信息 */} + + + 时长 + {detail.duration} + + + 课时 + {detail.lesson_count}节 + + + 难度 + 中级 + + + + {/* 课程简介 */} + + 课程简介 + {detail.description} + + + {/* 详情长图 */} + + 课程详情 + {detail.display_detail_image || detail.detail_image_url ? ( + + ) : ( + + 暂无详情长图 + + )} + + + + + {/* 底部栏 */} + + + 总价: + ¥{detail.price} + + + ) } diff --git a/miniprogram/src/pages/courses/index.tsx b/miniprogram/src/pages/courses/index.tsx index b984c0f..eda05fd 100644 --- a/miniprogram/src/pages/courses/index.tsx +++ b/miniprogram/src/pages/courses/index.tsx @@ -57,7 +57,7 @@ export default function CourseIndex() { {item.title} {item.description} - + )) diff --git a/miniprogram/src/pages/order/checkout.tsx b/miniprogram/src/pages/order/checkout.tsx index 34f4aa6..5996fdd 100644 --- a/miniprogram/src/pages/order/checkout.tsx +++ b/miniprogram/src/pages/order/checkout.tsx @@ -1,7 +1,7 @@ import { View, Text, Image, ScrollView, Button } from '@tarojs/components' import Taro, { useRouter, useLoad } from '@tarojs/taro' import { useState, useMemo } from 'react' -import { getConfigDetail, createOrder } from '../../api' +import { getConfigDetail, createOrder, getVBCourseDetail } from '../../api' import { getSelectedItems, removeItem } from '../../utils/cart' import './checkout.scss' @@ -34,15 +34,28 @@ export default function Checkout() { setLoading(false) } else if (params.id) { try { - const res = await getConfigDetail(params.id) - setItems([{ - id: res.id, - name: res.name, - price: res.price, - image: res.static_image_url || res.detail_image_url, - quantity: Number(params.quantity) || 1, - description: res.description - }]) + 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([{ + id: res.id, + name: res.name, + price: res.price, + image: res.static_image_url || res.detail_image_url, + quantity: Number(params.quantity) || 1, + description: res.description + }]) + } } catch (err) { console.error(err) Taro.showToast({ title: '商品加载失败', icon: 'none' }) @@ -93,7 +106,8 @@ export default function Checkout() { quantity: item.quantity, customer_name: address.userName, 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) })