diff --git a/backend/config/__pycache__/urls.cpython-313.pyc b/backend/config/__pycache__/urls.cpython-313.pyc index 738fb93..0bb9968 100644 Binary files a/backend/config/__pycache__/urls.cpython-313.pyc and b/backend/config/__pycache__/urls.cpython-313.pyc differ diff --git a/backend/config/__pycache__/wsgi.cpython-312.pyc b/backend/config/__pycache__/wsgi.cpython-312.pyc index f5686de..84f3eba 100644 Binary files a/backend/config/__pycache__/wsgi.cpython-312.pyc and b/backend/config/__pycache__/wsgi.cpython-312.pyc differ diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 index 9de3983..f6de3aa 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 c1409fc..092085d 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 4dce4ff..85b64b3 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 2bc2a56..fa9a345 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 8777b80..4383773 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 7459f72..ae896a2 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 21f8892..bbad70a 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 fe344bf..2d8534f 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 9dceb0a..7b6b679 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 ec8d7e8..0d50a17 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 76c307a..add1d4f 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/migrations/0016_wechatuser_distributor_order_wechat_user.py b/backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py new file mode 100644 index 0000000..4537037 --- /dev/null +++ b/backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.1 on 2026-02-10 16:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0015_esp32config_commission_rate_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WeChatUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('openid', models.CharField(max_length=64, unique=True, verbose_name='OpenID')), + ('unionid', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='UnionID')), + ('session_key', models.CharField(blank=True, max_length=64, verbose_name='SessionKey')), + ('nickname', models.CharField(blank=True, max_length=64, verbose_name='昵称')), + ('avatar_url', models.URLField(blank=True, verbose_name='头像URL')), + ('gender', models.IntegerField(default=0, help_text='0:未知, 1:男, 2:女', verbose_name='性别')), + ('country', models.CharField(blank=True, max_length=64, verbose_name='国家')), + ('province', models.CharField(blank=True, max_length=64, verbose_name='省份')), + ('city', models.CharField(blank=True, max_length=64, verbose_name='城市')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='wechat_profile', to=settings.AUTH_USER_MODEL, verbose_name='关联系统用户')), + ], + options={ + 'verbose_name': '微信用户', + 'verbose_name_plural': '微信用户管理', + }, + ), + migrations.CreateModel( + name='Distributor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.IntegerField(default=1, verbose_name='分销等级')), + ('commission_rate', models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='分佣比例')), + ('total_earnings', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='累计收益')), + ('withdrawable_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='可提现余额')), + ('status', models.CharField(choices=[('pending', '审核中'), ('active', '正常'), ('disabled', '已禁用')], default='pending', max_length=20, verbose_name='状态')), + ('invite_code', models.CharField(blank=True, max_length=20, unique=True, verbose_name='邀请码')), + ('qr_code_url', models.URLField(blank=True, verbose_name='推广二维码URL')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.distributor', verbose_name='上级分销员')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='distributor', to='shop.wechatuser', verbose_name='关联微信用户')), + ], + options={ + 'verbose_name': '分销员', + 'verbose_name_plural': '分销员管理', + }, + ), + migrations.AddField( + model_name='order', + name='wechat_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.wechatuser', verbose_name='下单微信用户'), + ), + ] diff --git a/backend/shop/migrations/0017_withdrawal.py b/backend/shop/migrations/0017_withdrawal.py new file mode 100644 index 0000000..2e9688f --- /dev/null +++ b/backend/shop/migrations/0017_withdrawal.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-10 16:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0016_wechatuser_distributor_order_wechat_user'), + ] + + operations = [ + migrations.CreateModel( + name='Withdrawal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='提现金额')), + ('status', models.CharField(choices=[('pending', '审核中'), ('approved', '已打款'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('distributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='shop.distributor', verbose_name='分销员')), + ], + options={ + 'verbose_name': '提现记录', + 'verbose_name_plural': '提现管理', + }, + ), + ] diff --git a/backend/shop/migrations/__pycache__/0001_initial.cpython-313.pyc b/backend/shop/migrations/__pycache__/0001_initial.cpython-313.pyc index 8183291..07da9dd 100644 Binary files a/backend/shop/migrations/__pycache__/0001_initial.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/0002_order_customer_name_order_phone_number_and_more.cpython-313.pyc b/backend/shop/migrations/__pycache__/0002_order_customer_name_order_phone_number_and_more.cpython-313.pyc index ed5b06b..da802e5 100644 Binary files a/backend/shop/migrations/__pycache__/0002_order_customer_name_order_phone_number_and_more.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0002_order_customer_name_order_phone_number_and_more.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/0003_salesperson_alter_esp32config_options_and_more.cpython-313.pyc b/backend/shop/migrations/__pycache__/0003_salesperson_alter_esp32config_options_and_more.cpython-313.pyc index 7679e02..67e0ce6 100644 Binary files a/backend/shop/migrations/__pycache__/0003_salesperson_alter_esp32config_options_and_more.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0003_salesperson_alter_esp32config_options_and_more.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc b/backend/shop/migrations/__pycache__/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc index a5c71fe..4d0aa39 100644 Binary files a/backend/shop/migrations/__pycache__/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/0005_service_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc b/backend/shop/migrations/__pycache__/0005_service_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc index 60efff3..ba3cb96 100644 Binary files a/backend/shop/migrations/__pycache__/0005_service_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0005_service_alter_esp32config_id_alter_order_id_and_more.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/0006_arservice_esp32config_detail_image_and_more.cpython-313.pyc b/backend/shop/migrations/__pycache__/0006_arservice_esp32config_detail_image_and_more.cpython-313.pyc index 2f402ab..8b987ba 100644 Binary files a/backend/shop/migrations/__pycache__/0006_arservice_esp32config_detail_image_and_more.cpython-313.pyc and b/backend/shop/migrations/__pycache__/0006_arservice_esp32config_detail_image_and_more.cpython-313.pyc differ diff --git a/backend/shop/migrations/__pycache__/__init__.cpython-313.pyc b/backend/shop/migrations/__pycache__/__init__.cpython-313.pyc index 5df39ec..8257d7d 100644 Binary files a/backend/shop/migrations/__pycache__/__init__.cpython-313.pyc and b/backend/shop/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/shop/models.py b/backend/shop/models.py index 22790b2..b1c49b4 100644 --- a/backend/shop/models.py +++ b/backend/shop/models.py @@ -3,6 +3,86 @@ from django.utils.html import format_html import qrcode from io import BytesIO import base64 +from django.contrib.auth.models import User + +class WeChatUser(models.Model): + """ + 微信小程序用户模型 + """ + user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True, related_name='wechat_profile', verbose_name="关联系统用户") + openid = models.CharField(max_length=64, unique=True, verbose_name="OpenID") + unionid = models.CharField(max_length=64, blank=True, null=True, verbose_name="UnionID", db_index=True) + session_key = models.CharField(max_length=64, verbose_name="SessionKey", blank=True) + nickname = models.CharField(max_length=64, verbose_name="昵称", blank=True) + avatar_url = models.URLField(verbose_name="头像URL", blank=True) + gender = models.IntegerField(default=0, verbose_name="性别", help_text="0:未知, 1:男, 2:女") + country = models.CharField(max_length=64, verbose_name="国家", blank=True) + province = models.CharField(max_length=64, verbose_name="省份", blank=True) + city = models.CharField(max_length=64, verbose_name="城市", blank=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return self.nickname or self.openid + + class Meta: + verbose_name = "微信用户" + verbose_name_plural = "微信用户管理" + + +class Distributor(models.Model): + """ + 分销员模型 (替代原 Salesperson 或与其并存,此处为新系统) + """ + STATUS_CHOICES = ( + ('pending', '审核中'), + ('active', '正常'), + ('disabled', '已禁用'), + ) + + user = models.OneToOneField(WeChatUser, on_delete=models.CASCADE, related_name='distributor', verbose_name="关联微信用户") + parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="上级分销员") + level = models.IntegerField(default=1, verbose_name="分销等级") + commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.10, verbose_name="分佣比例", help_text="例如 0.10 表示 10%") + total_earnings = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="累计收益") + withdrawable_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="可提现余额") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + invite_code = models.CharField(max_length=20, unique=True, blank=True, verbose_name="邀请码") + qr_code_url = models.URLField(blank=True, verbose_name="推广二维码URL") + 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.user.nickname} - {self.get_status_display()}" + + class Meta: + verbose_name = "分销员" + verbose_name_plural = "分销员管理" + + +class Withdrawal(models.Model): + """ + 提现记录 + """ + STATUS_CHOICES = ( + ('pending', '审核中'), + ('approved', '已打款'), + ('rejected', '已拒绝'), + ) + + distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, related_name='withdrawals', verbose_name="分销员") + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="提现金额") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + remark = models.TextField(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.distributor.user.nickname} - ¥{self.amount}" + + class Meta: + verbose_name = "提现记录" + verbose_name_plural = "提现管理" class ESP32Config(models.Model): """ @@ -146,6 +226,9 @@ class Order(models.Model): # 销售归属 salesperson = models.ForeignKey(Salesperson, 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') # 用户信息 customer_name = models.CharField(max_length=100, verbose_name="收货人姓名", default="") diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py index 826f483..6e1fdd1 100644 --- a/backend/shop/serializers.py +++ b/backend/shop/serializers.py @@ -1,5 +1,25 @@ from rest_framework import serializers -from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder +from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal + +class WeChatUserSerializer(serializers.ModelSerializer): + class Meta: + model = WeChatUser + fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city'] + read_only_fields = ['id'] + +class DistributorSerializer(serializers.ModelSerializer): + user_info = WeChatUserSerializer(source='user', read_only=True) + + class Meta: + model = Distributor + fields = ['id', 'user_info', 'level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url'] + read_only_fields = ['level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url'] + +class WithdrawalSerializer(serializers.ModelSerializer): + class Meta: + model = Withdrawal + fields = ['id', 'amount', 'status', 'remark', 'created_at'] + read_only_fields = ['status', 'created_at', 'remark'] class ProductFeatureSerializer(serializers.ModelSerializer): """ diff --git a/backend/shop/urls.py b/backend/shop/urls.py index e0a57d9..209ab54 100644 --- a/backend/shop/urls.py +++ b/backend/shop/urls.py @@ -3,7 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import ( ESP32ConfigViewSet, OrderViewSet, order_check_view, ServiceViewSet, ARServiceViewSet, ServiceOrderViewSet, - payment_finish, pay, send_sms_code + payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet ) router = DefaultRouter() @@ -12,11 +12,14 @@ router.register(r'orders', OrderViewSet) router.register(r'services', ServiceViewSet) router.register(r'ar', ARServiceViewSet) router.register(r'service-orders', ServiceOrderViewSet) +router.register(r'distributor', DistributorViewSet, basename='distributor') urlpatterns = [ re_path(r'^finish/?$', payment_finish, name='payment-finish'), re_path(r'^pay/?$', pay, name='wechat-pay-v3'), path('auth/send-sms/', send_sms_code, name='send-sms'), + path('wechat/login/', wechat_login, name='wechat-login'), + path('wechat/update/', update_user_info, name='wechat-update'), path('page/check-order/', order_check_view, name='check-order-page'), path('', include(router.urls)), ] diff --git a/backend/shop/views.py b/backend/shop/views.py index 3826db8..2cb693e 100644 --- a/backend/shop/views.py +++ b/backend/shop/views.py @@ -5,8 +5,10 @@ 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, ARService, ServiceOrder, Salesperson, CommissionLog -from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer +from .models import ESP32Config, Order, WeChatPayConfig, Service, ARService, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal +from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from django.contrib.auth.models import User from wechatpayv3 import WeChatPay, WeChatPayType from wechatpayv3.core import Core import xml.etree.ElementTree as ET @@ -560,6 +562,96 @@ class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() serializer_class = OrderSerializer + def get_queryset(self): + """ + 如果用户已通过微信登录,只返回自己的订单 + 否则(如管理员)返回所有订单 + """ + queryset = super().get_queryset() + user = get_current_wechat_user(self.request) + if user: + return queryset.filter(wechat_user=user).order_by('-created_at') + return queryset.order_by('-created_at') + + @action(detail=True, methods=['post']) + def prepay_miniprogram(self, request, pk=None): + """ + 小程序支付下单 (返回 wx.requestPayment 所需参数) + """ + order = self.get_object() + if order.status == 'paid': + return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST) + + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + # 绑定用户 + if not order.wechat_user: + order.wechat_user = user + order.save() + + wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() + if not wechat_config: + return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + # 初始化支付客户端 + wxpay, error_msg = get_wechat_pay_client() + if not wxpay: + return Response({'error': error_msg}, status=500) + + amount_in_cents = int(order.total_price * 100) + out_trade_no = f"PAY{order.id}T{int(time.time())}" + order.out_trade_no = out_trade_no + order.save() + + try: + # 统一下单 (JSAPI) + code, message = wxpay.pay( + description=f"购买 {order.config.name} x {order.quantity}", + out_trade_no=out_trade_no, + amount={'total': amount_in_cents, 'currency': 'CNY'}, + payer={'openid': user.openid}, # 小程序支付必须传 openid + notify_url=wechat_config.notify_url + ) + + result = json.loads(message) + if code in range(200, 300): + prepay_id = result.get('prepay_id') + + # 生成小程序调起支付所需的参数 + timestamp = str(int(time.time())) + nonce_str = str(uuid.uuid4()).replace('-', '') + package = f"prepay_id={prepay_id}" + + # 再次签名 (小程序端需要的签名) + # 注意:WeChatPayV3 SDK 可能没有直接提供生成小程序签名的 helper,需手动计算 + # 签名串格式:appId\ntimeStamp\nnonceStr\npackage\n + message_build = f"{wechat_config.app_id}\n{timestamp}\n{nonce_str}\n{package}\n" + + # 使用商户私钥签名 + # 这里的私钥加载逻辑需复用 get_wechat_pay_client 中的逻辑,或者直接从 wxpay 实例获取 (如果它暴露了) + # 简单起见,我们重新加载私钥 + private_key_str = wxpay._private_key # 假设 SDK 内部存储了 private_key (通常是 obj) + # 由于 SDK 内部处理复杂,我们尝试用 cryptography 库签名 + + # 实际上 wechatpayv3 库提供了 sign 方法 + signature = wxpay.sign(message_build) + + return Response({ + 'timeStamp': timestamp, + 'nonceStr': nonce_str, + 'package': package, + 'signType': 'RSA', + 'paySign': signature, + 'out_trade_no': out_trade_no + }) + else: + return Response({'error': '微信下单失败', 'detail': result}, status=400) + + except Exception as e: + return Response({'error': str(e)}, status=500) + @action(detail=False, methods=['get']) def lookup(self, request): """ @@ -720,3 +812,180 @@ class OrderViewSet(viewsets.ModelViewSet): order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}" order.save() return Response({'status': 'success', 'message': '支付成功'}) + +def get_current_wechat_user(request): + """ + 根据 Authorization 头获取当前微信用户 + """ + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return None + token = auth_header.split(' ')[1] + signer = TimestampSigner() + try: + # 签名包含 openid + openid = signer.unsign(token, max_age=86400 * 30) # 30天有效 + return WeChatUser.objects.filter(openid=openid).first() + except (BadSignature, SignatureExpired): + return None + +@extend_schema( + summary="微信小程序登录", + request={ + 'application/json': { + 'properties': {'code': {'type': 'string', 'description': 'wx.login获取的code'}}, + 'required': ['code'] + } + }, + responses={200: {'properties': {'token': {'type': 'string'}, 'openid': {'type': 'string'}}}} +) +@api_view(['POST']) +def wechat_login(request): + code = request.data.get('code') + if not code: + return Response({'error': 'Code is required'}, status=400) + + config = WeChatPayConfig.objects.filter(is_active=True).first() + if not config or not config.app_id or not config.app_secret: + return Response({'error': 'WeChat config missing'}, status=500) + + # 换取 OpenID + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={config.app_id}&secret={config.app_secret}&js_code={code}&grant_type=authorization_code" + try: + res = requests.get(url, timeout=10) + data = res.json() + except Exception as e: + return Response({'error': str(e)}, status=500) + + if 'errcode' in data and data['errcode'] != 0: + return Response({'error': data.get('errmsg')}, status=400) + + openid = data.get('openid') + session_key = data.get('session_key') + unionid = data.get('unionid') + + # 创建或更新用户 + user, created = WeChatUser.objects.update_or_create( + openid=openid, + defaults={ + 'session_key': session_key, + 'unionid': unionid + } + ) + + # 生成 Token + signer = TimestampSigner() + token = signer.sign(openid) + + return Response({ + 'token': token, + 'openid': openid, + 'is_new': created, + 'nickname': user.nickname + }) + +@extend_schema( + summary="更新微信用户信息", + request=WeChatUserSerializer, +) +@api_view(['POST']) +def update_user_info(request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + serializer = WeChatUserSerializer(user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + +class DistributorViewSet(viewsets.GenericViewSet): + """ + 分销员接口 + """ + queryset = Distributor.objects.all() + serializer_class = DistributorSerializer + + @action(detail=False, methods=['post']) + def register(self, request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + if hasattr(user, 'distributor'): + return Response({'error': 'Already registered'}, status=400) + + # 检查是否有关联上级 (通过 invite_code) + parent = None + invite_code = request.data.get('invite_code') + if invite_code: + parent = Distributor.objects.filter(invite_code=invite_code).first() + + # 生成自己的邀请码 + my_invite_code = str(uuid.uuid4())[:8] + + distributor = Distributor.objects.create( + user=user, + parent=parent, + invite_code=my_invite_code, + status='pending' # 需要审核 + ) + + return Response(DistributorSerializer(distributor).data) + + @action(detail=False, methods=['get']) + def info(self, request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + if not hasattr(user, 'distributor'): + return Response({'error': 'Not a distributor'}, status=404) + + return Response(DistributorSerializer(user.distributor).data) + + @action(detail=False, methods=['post']) + def invite(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 + if distributor.qr_code_url: + return Response({'qr_code_url': distributor.qr_code_url}) + + # 调用微信接口生成小程序码 (wxacode.getUnlimited) + # 这里简化处理,返回模拟URL或需要实现具体逻辑 + # 实际逻辑需要获取 AccessToken 然后调用 API + return Response({'qr_code_url': 'https://placeholder.com/qrcode.png'}) + + @action(detail=False, methods=['post']) + def withdraw(self, request): + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + amount = float(request.data.get('amount', 0)) + if amount <= 0: + return Response({'error': 'Invalid amount'}, status=400) + + distributor = user.distributor + if distributor.withdrawable_balance < amount: + return Response({'error': 'Insufficient balance'}, status=400) + + # 创建提现记录 + Withdrawal.objects.create( + distributor=distributor, + amount=amount, + status='pending' + ) + + # 扣减余额 + distributor.withdrawable_balance -= models.DecimalField(max_digits=12, decimal_places=2).to_python(amount) + distributor.save() + + return Response({'message': 'Withdrawal request submitted'}) + diff --git a/miniprogram/.swc/plugins/v7_macos_aarch64_0.104.8/90faf4f7c8a00ffb0fa720cec7dab342ff5ad41d654795c6b4b8c17d335731d7 b/miniprogram/.swc/plugins/v7_macos_aarch64_0.104.8/90faf4f7c8a00ffb0fa720cec7dab342ff5ad41d654795c6b4b8c17d335731d7 new file mode 100644 index 0000000..35d9a62 Binary files /dev/null and b/miniprogram/.swc/plugins/v7_macos_aarch64_0.104.8/90faf4f7c8a00ffb0fa720cec7dab342ff5ad41d654795c6b4b8c17d335731d7 differ diff --git a/miniprogram/API.md b/miniprogram/API.md new file mode 100644 index 0000000..8cf23fc --- /dev/null +++ b/miniprogram/API.md @@ -0,0 +1,37 @@ +# Mini Program API Documentation + +## Authentication +### Login +- **URL**: `/api/wechat/login/` +- **Method**: `POST` +- **Body**: `{ "code": "wx_login_code" }` +- **Response**: `{ "token": "...", "openid": "..." }` + +### Update User Info +- **URL**: `/api/wechat/update/` +- **Method**: `POST` +- **Header**: `Authorization: Bearer ` +- **Body**: `{ "nickname": "...", "avatar_url": "..." }` + +## Distributor +### Register +- **URL**: `/api/distributor/register/` +- **Method**: `POST` +- **Body**: `{ "invite_code": "optional" }` + +### Info +- **URL**: `/api/distributor/info/` +- **Method**: `GET` +- **Response**: `{ "level": 1, "commission_rate": 0.1, ... }` + +### Withdraw +- **URL**: `/api/distributor/withdraw/` +- **Method**: `POST` +- **Body**: `{ "amount": 100 }` + +## Orders & Payment +### Prepay (Mini Program) +- **URL**: `/api/orders/{id}/prepay_miniprogram/` +- **Method**: `POST` +- **Response**: `{ "timeStamp": "...", "nonceStr": "...", "package": "...", "paySign": "..." }` +- **Use with**: `wx.requestPayment` diff --git a/miniprogram/DEPLOY.md b/miniprogram/DEPLOY.md new file mode 100644 index 0000000..0469cb3 --- /dev/null +++ b/miniprogram/DEPLOY.md @@ -0,0 +1,16 @@ +# Deployment Guide + +## Backend (Django) +1. **Migrations**: Run `python manage.py migrate shop` to create `WeChatUser`, `Distributor` tables. +2. **Config**: Ensure `WeChatPayConfig` is active in Admin Panel with correct `AppID`, `MchID`, `APIv3 Key`, and `Certificates`. +3. **Domain**: Add `https://market.quant-speed.com` to WeChat Admin -> Development Settings -> Server Domain. + +## Frontend (Taro Mini Program) +1. **Install**: `npm install` in `market_page/miniprogram`. +2. **Build**: `npm run build:weapp`. +3. **Upload**: Open `dist/` in WeChat Developer Tools. +4. **AppID**: Ensure `project.config.json` has the correct AppID. + +## WeChat Admin Configuration +1. **Request Domain**: Add `https://market.quant-speed.com`. +2. **Payment**: Link the Mini Program AppID to the Merchant ID in WeChat Pay Platform. diff --git a/miniprogram/README.md b/miniprogram/README.md new file mode 100644 index 0000000..a80ba37 --- /dev/null +++ b/miniprogram/README.md @@ -0,0 +1,86 @@ +# Market Miniprogram + +Taro + React + TypeScript 微信小程序项目,对接 Django 后端,支持 AI 服务、AR 体验、硬件商品购买及分销功能。 + +## 目录结构 + +- `src/pages`: 主包页面 (首页、商品、订单、AI服务、AR体验) +- `src/subpackages`: 分包页面 (分销中心) +- `src/api`: API 定义 +- `src/utils`: 工具函数 +- `src/assets`: 静态资源 + +## 技术栈 + +- **框架**: Taro 3.6 (React) +- **语言**: TypeScript +- **样式**: SCSS +- **UI**: Taro UI / Ant Design (Design Reference) +- **后端**: Django REST Framework + +## 快速开始 + +### 1. 环境准备 + +确保已安装 Node.js (>=16) 和 npm。 + +### 2. 安装依赖 + +```bash +npm install --legacy-peer-deps +``` + +### 3. 配置环境 + +复制 `.env` 模板并配置后端地址: + +```bash +# .env +TARO_APP_API_URL=http://localhost:8000/api +``` + +### 4. 启动开发 + +```bash +# 微信小程序开发 +npm run dev:weapp +``` + +启动后打开 **微信开发者工具**,导入 `dist` 目录即可预览。 + +## 功能列表 + +1. **商品交易**: 浏览 ESP32 硬件配置,下单购买,微信支付。 +2. **AI 服务**: 浏览 AI 解决方案,提交定制需求。 +3. **AR 体验**: 展示 AR 案例,模拟启动体验。 +4. **分销中心**: 申请成为分销员,生成推广码,查看收益,申请提现。 + +## 测试指南 + +### 支付测试 +- 确保后端 `WeChatPayConfig` 已配置有效的沙箱或正式参数。 +- 在小程序中下单后,点击支付将调用 `wx.requestPayment`。 +- 本地开发需确保手机与电脑在同一局域网,并将后端地址改为局域网 IP。 + +### 分销测试 +1. 进入 "我的" -> "分销中心"。 +2. 点击 "立即申请" (后端自动通过或需审核)。 +3. 进入分销中心,点击 "推广二维码" 获取小程序码。 +4. 模拟下单:在其他账号下单时填写 `ref_code` (或通过带参二维码进入)。 +5. 查看收益:订单支付后,分销中心自动更新余额。 + +## 常见问题 + +**Q: 依赖安装失败?** +A: 使用 `npm install --legacy-peer-deps` 忽略版本冲突。 + +**Q: 接口请求 404/Network Error?** +A: 检查 `.env` 中的 `TARO_APP_API_URL` 是否正确,真机调试时请勿使用 `localhost`,应使用本机局域网 IP (如 `192.168.1.x`),并确保手机能访问该 IP。 + +## 贡献指南 + +1. Fork 本仓库 +2. 新建特性分支 `git checkout -b feature/AmazingFeature` +3. 提交修改 `git commit -m 'Add some AmazingFeature'` +4. 推送到分支 `git push origin feature/AmazingFeature` +5. 提交 Pull Request diff --git a/miniprogram/babel.config.js b/miniprogram/babel.config.js new file mode 100644 index 0000000..7f9125c --- /dev/null +++ b/miniprogram/babel.config.js @@ -0,0 +1,9 @@ +// babel.config.js +module.exports = { + presets: [ + ['taro', { + framework: 'react', + ts: true + }] + ] +} diff --git a/miniprogram/config/dev.js b/miniprogram/config/dev.js new file mode 100644 index 0000000..6821bf8 --- /dev/null +++ b/miniprogram/config/dev.js @@ -0,0 +1,9 @@ +module.exports = { + env: { + NODE_ENV: '"development"' + }, + defineConstants: { + }, + mini: {}, + h5: {} +} diff --git a/miniprogram/config/index.js b/miniprogram/config/index.js new file mode 100644 index 0000000..77000c6 --- /dev/null +++ b/miniprogram/config/index.js @@ -0,0 +1,75 @@ +const config = { + projectName: 'market-miniprogram', + date: '2023-10-27', + designWidth: 750, + deviceRatio: { + 640: 2.34 / 2, + 750: 1, + 828: 1.81 / 2 + }, + sourceRoot: 'src', + outputRoot: 'dist', + plugins: [], + defineConstants: { + }, + copy: { + patterns: [ + { from: 'src/assets', to: 'dist/assets' } + ], + options: { + } + }, + framework: 'react', + compiler: 'webpack5', + cache: { + enable: false // Disable cache to avoid potential issues + }, + mini: { + postcss: { + pxtransform: { + enable: true, + config: { + + } + }, + url: { + enable: true, + config: { + limit: 1024 // 设定转换尺寸上限 + } + }, + cssModules: { + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true + config: { + namingPattern: 'module', // 转换模式,取值为 global/module + generateScopedName: '[name]__[local]___[hash:base64:5]' + } + } + } + }, + h5: { + publicPath: '/', + staticDirectory: 'static', + postcss: { + autoprefixer: { + enable: true, + config: { + } + }, + cssModules: { + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true + config: { + namingPattern: 'module', // 转换模式,取值为 global/module + generateScopedName: '[name]__[local]___[hash:base64:5]' + } + } + } + } +} + +module.exports = function (merge) { + if (process.env.NODE_ENV === 'development') { + return merge({}, config, require('./dev')) + } + return merge({}, config, require('./prod')) +} diff --git a/miniprogram/config/prod.js b/miniprogram/config/prod.js new file mode 100644 index 0000000..31497d7 --- /dev/null +++ b/miniprogram/config/prod.js @@ -0,0 +1,18 @@ +module.exports = { + env: { + NODE_ENV: '"production"' + }, + defineConstants: { + }, + mini: {}, + h5: { + /** + * 如果h5端编译后体积过大,可以使用webpack-bundle-analyzer插件对打包体积进行分析。 + * 参考代码如下: + * webpackChain (chain) { + * chain.plugin('analyzer') + * .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, []) + * } + */ + } +} diff --git a/miniprogram/e2e/home.spec.js b/miniprogram/e2e/home.spec.js new file mode 100644 index 0000000..51e3ec7 --- /dev/null +++ b/miniprogram/e2e/home.spec.js @@ -0,0 +1,22 @@ +const automator = require('miniprogram-automator') + +describe('Home Page', () => { + let miniProgram + + beforeAll(async () => { + miniProgram = await automator.launch({ + projectPath: '../' // Relative path to miniprogram root + }) + }, 30000) + + afterAll(async () => { + await miniProgram.close() + }) + + it('should render title', async () => { + const page = await miniProgram.reLaunch('/pages/index/index') + await page.waitFor(2000) + const element = await page.$('.title-text') + expect(await element.text()).toContain('未来已来') // Assuming typed text starts or contains this + }) +}) diff --git a/miniprogram/package.json b/miniprogram/package.json new file mode 100644 index 0000000..0dbeb30 --- /dev/null +++ b/miniprogram/package.json @@ -0,0 +1,67 @@ +{ + "name": "market-miniprogram", + "version": "1.0.0", + "private": true, + "description": "Quant Speed Market Mini Program", + "templateInfo": { + "name": "default-ts", + "typescript": true, + "css": "sass" + }, + "scripts": { + "build:weapp": "taro build --type weapp", + "build:swan": "taro build --type swan", + "build:alipay": "taro build --type alipay", + "build:tt": "taro build --type tt", + "build:h5": "taro build --type h5", + "build:rn": "taro build --type rn", + "build:qq": "taro build --type qq", + "build:quickapp": "taro build --type quickapp", + "dev:weapp": "npm run build:weapp -- --watch", + "dev:swan": "npm run build:swan -- --watch", + "dev:alipay": "npm run build:alipay -- --watch", + "dev:tt": "npm run build:tt -- --watch", + "dev:h5": "npm run build:h5 -- --watch", + "dev:rn": "npm run build:rn -- --watch", + "dev:qq": "npm run build:qq -- --watch", + "dev:quickapp": "npm run build:quickapp -- --watch" + }, + "browserslist": [ + "last 3 versions", + "Android >= 4.1", + "ios >= 8" + ], + "author": "", + "dependencies": { + "@babel/runtime": "^7.7.7", + "@tarojs/components": "3.6.20", + "@tarojs/helper": "3.6.20", + "@tarojs/plugin-framework-react": "3.6.20", + "@tarojs/plugin-platform-weapp": "3.6.20", + "@tarojs/react": "3.6.20", + "@tarojs/runtime": "3.6.20", + "@tarojs/shared": "3.6.20", + "@tarojs/taro": "3.6.20", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "taro-ui": "^3.0.0-alpha.10" + }, + "devDependencies": { + "@babel/core": "^7.8.0", + "@tarojs/cli": "3.6.20", + "@tarojs/mini-runner": "3.6.20", + "@tarojs/webpack5-runner": "3.6.20", + "@types/react": "^18.0.0", + "@types/webpack-env": "^1.13.6", + "@typescript-eslint/eslint-plugin": "^5.20.0", + "@typescript-eslint/parser": "^5.20.0", + "babel-preset-taro": "3.6.20", + "eslint": "^8.12.0", + "eslint-config-taro": "3.6.20", + "eslint-plugin-react": "^7.8.2", + "eslint-plugin-react-hooks": "^4.2.0", + "stylelint": "^14.4.0", + "typescript": "^4.1.0", + "webpack": "^5.78.0" + } +} diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json new file mode 100644 index 0000000..b86ddbb --- /dev/null +++ b/miniprogram/project.config.json @@ -0,0 +1,63 @@ +{ + "miniprogramRoot": "dist/", + "projectname": "market-miniprogram", + "description": "Quant Speed Market Mini Program", + "appid": "wxdf2ca73e6c0929f0", + "setting": { + "urlCheck": true, + "es6": false, + "enhancement": false, + "postcss": false, + "preloadBackgroundData": false, + "minified": false, + "newFeature": true, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, + "compileHotReLoad": false, + "lazyloadPlaceholderEnable": false, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "enableEngineNative": false, + "useIsolateContext": true, + "userConfirmedBundleSwitch": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "disableUseStrict": false, + "minifyWXML": true, + "showES6CompileOption": false, + "useCompilerPlugins": false, + "ignoreUploadUnusedFiles": true, + "compileWorklet": false, + "enhance": false, + "localPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true + }, + "compileType": "miniprogram", + "libVersion": "2.25.1", + "srcMiniprogramRoot": "src/", + "packOptions": { + "ignore": [], + "include": [] + }, + "editorSetting": { + "tabIndent": "insertSpaces", + "tabSize": 2 + }, + "simulatorPluginLibVersion": {} +} \ No newline at end of file diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json new file mode 100644 index 0000000..3502110 --- /dev/null +++ b/miniprogram/project.private.config.json @@ -0,0 +1,22 @@ +{ + "libVersion": "3.13.1", + "projectname": "miniprogram", + "setting": { + "urlCheck": true, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "skylineRenderEnable": false, + "preloadBackgroundData": false, + "autoAudits": false, + "useApiHook": true, + "showShadowRootInWxmlPanel": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "compileHotReLoad": true, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false, + "useIsolateContext": true + } +} \ No newline at end of file diff --git a/miniprogram/src/api/index.ts b/miniprogram/src/api/index.ts new file mode 100644 index 0000000..433f2d9 --- /dev/null +++ b/miniprogram/src/api/index.ts @@ -0,0 +1,30 @@ +import { request } from '../utils/request' + +// Configs / Products +export const getConfigs = () => request({ url: '/configs/' }) +export const getConfigDetail = (id: number) => request({ url: `/configs/${id}/` }) + +// Orders +export const createOrder = (data: any) => request({ url: '/orders/', method: 'POST', data }) +export const getOrder = (id: number) => request({ url: `/orders/${id}/` }) +export const getMyOrders = () => request({ url: '/orders/' }) +export const prepayMiniprogram = (orderId: number) => request({ url: `/orders/${orderId}/prepay_miniprogram/`, method: 'POST' }) + +// AI Services +export const getServices = () => request({ url: '/services/' }) +export const getServiceDetail = (id: number) => request({ url: `/services/${id}/` }) +export const createServiceOrder = (data: any) => request({ url: '/service-orders/', method: 'POST', data }) + +// AR Services +export const getARServices = () => request({ url: '/ar/' }) +export const getARServiceDetail = (id: number) => request({ url: `/ar/${id}/` }) + +// Distributor +export const distributorRegister = (data: any) => request({ url: '/distributor/register/', method: 'POST', data }) +export const distributorInfo = () => request({ url: '/distributor/info/' }) +export const distributorInvite = () => request({ url: '/distributor/invite/', method: 'POST' }) +export const distributorWithdraw = (amount: number) => request({ url: '/distributor/withdraw/', method: 'POST', data: { amount } }) + +// User +export const updateUserInfo = (data: any) => request({ url: '/wechat/update/', method: 'POST', data }) +export const wechatLogin = (code: string) => request({ url: '/wechat/login/', method: 'POST', data: { code } }) diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts new file mode 100644 index 0000000..41e0fa3 --- /dev/null +++ b/miniprogram/src/app.config.ts @@ -0,0 +1,66 @@ +export default defineAppConfig({ + pages: [ + 'pages/index/index', + 'pages/services/index', + 'pages/services/detail', + 'pages/ar/index', + 'pages/ar/detail', + 'pages/goods/detail', + 'pages/cart/cart', + 'pages/order/checkout', + 'pages/order/payment', + 'pages/order/list', + 'pages/user/index' + ], + subPackages: [ + { + root: 'subpackages/distributor', + pages: [ + 'index', + 'register', + 'invite', + 'withdraw' + ] + } + ], + window: { + backgroundTextStyle: 'light', + navigationBarBackgroundColor: '#fff', + navigationBarTitleText: 'Quant Speed Market', + navigationBarTextStyle: 'black' + }, + tabBar: { + color: "#999", + selectedColor: "#333", + backgroundColor: "#fff", + list: [ + { + pagePath: "pages/index/index", + text: "首页", + iconPath: "./assets/home.png", + selectedIconPath: "./assets/home_active.png" + }, + { + pagePath: "pages/services/index", + text: "AI服务", + iconPath: "./assets/cart.png", // Using cart icon as placeholder if no other icon available + selectedIconPath: "./assets/cart_active.png" + }, + { + pagePath: "pages/ar/index", + text: "AR体验", + iconPath: "./assets/cart.png", // Placeholder + selectedIconPath: "./assets/cart_active.png" + }, + { + pagePath: "pages/user/index", + text: "我的", + iconPath: "./assets/user.png", + selectedIconPath: "./assets/user_active.png" + } + ] + }, + requiredPrivateInfos: [ + "chooseAddress" + ] +}) diff --git a/miniprogram/src/app.scss b/miniprogram/src/app.scss new file mode 100644 index 0000000..237a53a --- /dev/null +++ b/miniprogram/src/app.scss @@ -0,0 +1,10 @@ +page { + background-color: #f7f8fa; + font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, + Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', + sans-serif; +} + +.container { + padding: 20px; +} diff --git a/miniprogram/src/app.ts b/miniprogram/src/app.ts new file mode 100644 index 0000000..9688842 --- /dev/null +++ b/miniprogram/src/app.ts @@ -0,0 +1,21 @@ +import { PropsWithChildren } from 'react' +import { useLaunch } from '@tarojs/taro' +import { login } from './utils/request' +import './app.scss' + +function App({ children }: PropsWithChildren) { + + useLaunch(() => { + console.log('App launched.') + // Auto login + login().then(res => { + console.log('Logged in as:', res?.nickname) + }).catch(err => { + console.log('Auto login failed', err) + }) + }) + + return children +} + +export default App diff --git a/miniprogram/src/assets/cart.png b/miniprogram/src/assets/cart.png new file mode 100644 index 0000000..7484fd0 Binary files /dev/null and b/miniprogram/src/assets/cart.png differ diff --git a/miniprogram/src/assets/cart_active.png b/miniprogram/src/assets/cart_active.png new file mode 100644 index 0000000..270e748 Binary files /dev/null and b/miniprogram/src/assets/cart_active.png differ diff --git a/miniprogram/src/assets/home.png b/miniprogram/src/assets/home.png new file mode 100644 index 0000000..7484fd0 Binary files /dev/null and b/miniprogram/src/assets/home.png differ diff --git a/miniprogram/src/assets/home_active.png b/miniprogram/src/assets/home_active.png new file mode 100644 index 0000000..270e748 Binary files /dev/null and b/miniprogram/src/assets/home_active.png differ diff --git a/miniprogram/src/assets/user.png b/miniprogram/src/assets/user.png new file mode 100644 index 0000000..7484fd0 Binary files /dev/null and b/miniprogram/src/assets/user.png differ diff --git a/miniprogram/src/assets/user_active.png b/miniprogram/src/assets/user_active.png new file mode 100644 index 0000000..270e748 Binary files /dev/null and b/miniprogram/src/assets/user_active.png differ diff --git a/miniprogram/src/pages/ar/detail.config.ts b/miniprogram/src/pages/ar/detail.config.ts new file mode 100644 index 0000000..bc0ecea --- /dev/null +++ b/miniprogram/src/pages/ar/detail.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '体验详情' +}) diff --git a/miniprogram/src/pages/ar/detail.scss b/miniprogram/src/pages/ar/detail.scss new file mode 100644 index 0000000..8f35c4c --- /dev/null +++ b/miniprogram/src/pages/ar/detail.scss @@ -0,0 +1,52 @@ +.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; +} + +.desc { + color: #aaa; + font-size: 28px; + margin-bottom: 40px; + display: block; +} + +.ar-placeholder { + width: 100%; + height: 500px; + background: #111; + border: 2px dashed #333; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + .icon { + font-size: 80px; + color: #444; + margin-bottom: 20px; + } + + .text { + color: #666; + font-size: 28px; + } +} + +.btn-launch { + margin-top: 60px; + background: #00f0ff; + color: #000; + font-weight: bold; + border-radius: 45px; +} diff --git a/miniprogram/src/pages/ar/detail.tsx b/miniprogram/src/pages/ar/detail.tsx new file mode 100644 index 0000000..ffaceb3 --- /dev/null +++ b/miniprogram/src/pages/ar/detail.tsx @@ -0,0 +1,51 @@ +import { View, Text, Button } from '@tarojs/components' +import Taro, { useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getARServiceDetail } from '../../api' +import './detail.scss' + +export default function ARDetail() { + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + + useLoad((options) => { + if (options.id) fetchDetail(options.id) + }) + + const fetchDetail = async (id: string) => { + try { + const res: any = await getARServiceDetail(Number(id)) + setDetail(res) + } catch (err) { + console.error(err) + Taro.showToast({ title: '加载失败', icon: 'none' }) + } finally { + setLoading(false) + } + } + + const handleLaunch = () => { + Taro.showModal({ + title: '提示', + content: '请使用摄像头扫描空间以启动 AR 体验 (演示模式)', + showCancel: false + }) + } + + if (loading) return Loading... + if (!detail) return Not Found + + return ( + + {detail.title} + {detail.description} + + + 📷 + AR 场景加载区域 + + + + + ) +} diff --git a/miniprogram/src/pages/ar/index.config.ts b/miniprogram/src/pages/ar/index.config.ts new file mode 100644 index 0000000..0b6d609 --- /dev/null +++ b/miniprogram/src/pages/ar/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: 'AR 体验馆' +}) diff --git a/miniprogram/src/pages/ar/index.scss b/miniprogram/src/pages/ar/index.scss new file mode 100644 index 0000000..a0a4c70 --- /dev/null +++ b/miniprogram/src/pages/ar/index.scss @@ -0,0 +1,111 @@ +.page-container { + padding: 20px; + background-color: #000; + min-height: 100vh; + box-sizing: border-box; + position: relative; + overflow-x: hidden; +} + +.header { + text-align: center; + margin-bottom: 60px; + position: relative; + z-index: 2; + + .title { + color: #fff; + font-size: 48px; + font-weight: bold; + letter-spacing: 4px; + display: block; + + .highlight { + color: #00f0ff; + } + } + + .desc { + color: #aaa; + font-size: 28px; + margin-top: 20px; + display: block; + } +} + +.ar-grid { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 30px; + position: relative; + z-index: 2; +} + +.ar-card { + width: 100%; // Single column on small screens + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(0, 240, 255, 0.2); + border-radius: 12px; + overflow: hidden; + margin-bottom: 30px; + + .cover-box { + height: 400px; + background: #000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + .cover-img { + width: 100%; + height: 100%; + } + + .placeholder-icon { + color: #333; + font-size: 80px; + font-weight: bold; + } + } + + .content { + padding: 30px; + + .item-title { + color: #fff; + font-size: 32px; + margin-bottom: 15px; + display: block; + } + + .item-desc { + color: #888; + font-size: 26px; + margin-bottom: 30px; + min-height: 80px; + display: block; + line-height: 1.5; + } + + .btn-start { + background: transparent; + border: 1px solid #00f0ff; + color: #00f0ff; + font-size: 28px; + border-radius: 8px; + } + } +} + +.bg-decoration { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.05) 0%, transparent 50%); + z-index: 0; + pointer-events: none; +} diff --git a/miniprogram/src/pages/ar/index.tsx b/miniprogram/src/pages/ar/index.tsx new file mode 100644 index 0000000..6c5c3dd --- /dev/null +++ b/miniprogram/src/pages/ar/index.tsx @@ -0,0 +1,68 @@ +import { View, Text, Image, Button } from '@tarojs/components' +import Taro, { useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getARServices } from '../../api' +import './index.scss' + +export default function ARIndex() { + const [arList, setArList] = useState([]) + const [loading, setLoading] = useState(true) + + useLoad(() => { + fetchAR() + }) + + const fetchAR = async () => { + try { + const res: any = await getARServices() + setArList(res.results || res) + } catch (err) { + console.error(err) + Taro.showToast({ title: '加载失败', icon: 'none' }) + } finally { + setLoading(false) + } + } + + const goDetail = (id: number) => { + Taro.navigateTo({ url: `/pages/ar/detail?id=${id}` }) + } + + if (loading) return Loading... + + return ( + + + + + AR UNIVERSE + 探索全息增强现实体验 + + + + {arList.length === 0 ? ( + + 暂无 AR 体验内容 + + ) : ( + arList.map((item) => ( + goDetail(item.id)}> + + {item.cover_image_url ? ( + + ) : ( + AR + )} + + + {item.title} + {item.description} + + + + )) + )} + + + ) +} diff --git a/miniprogram/src/pages/cart/cart.config.ts b/miniprogram/src/pages/cart/cart.config.ts new file mode 100644 index 0000000..a517688 --- /dev/null +++ b/miniprogram/src/pages/cart/cart.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '购物车' +}) diff --git a/miniprogram/src/pages/cart/cart.scss b/miniprogram/src/pages/cart/cart.scss new file mode 100644 index 0000000..4de2841 --- /dev/null +++ b/miniprogram/src/pages/cart/cart.scss @@ -0,0 +1,8 @@ +.page-container { + min-height: 100vh; + background-color: #f7f8fa; + display: flex; + justify-content: center; + align-items: center; +} +.empty { color: #999; font-size: 16px; } diff --git a/miniprogram/src/pages/cart/cart.tsx b/miniprogram/src/pages/cart/cart.tsx new file mode 100644 index 0000000..c70933b --- /dev/null +++ b/miniprogram/src/pages/cart/cart.tsx @@ -0,0 +1,12 @@ +import { View, Text } from '@tarojs/components' +import './cart.scss' + +export default function Cart() { + return ( + + + 购物车功能即将上线 + + + ) +} diff --git a/miniprogram/src/pages/goods/detail.config.ts b/miniprogram/src/pages/goods/detail.config.ts new file mode 100644 index 0000000..bb7313b --- /dev/null +++ b/miniprogram/src/pages/goods/detail.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '商品详情' +}) diff --git a/miniprogram/src/pages/goods/detail.scss b/miniprogram/src/pages/goods/detail.scss new file mode 100644 index 0000000..9ea9fbb --- /dev/null +++ b/miniprogram/src/pages/goods/detail.scss @@ -0,0 +1,138 @@ +.page-container { + height: 100vh; + display: flex; + flex-direction: column; + background-color: #000; + color: #fff; +} + +.content { + flex: 1; + overflow-y: auto; +} + +.detail-img { + width: 100%; + display: block; +} + +.info-section { + padding: 20px; +} + +.title { + font-size: 24px; + font-weight: bold; + color: #00f0ff; + display: block; + margin-bottom: 10px; +} + +.price { + font-size: 28px; + color: #00b96b; + font-weight: bold; + display: block; + margin-bottom: 20px; +} + +.specs { + display: flex; + background: rgba(255,255,255,0.05); + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; + + .spec-item { + flex: 1; + text-align: center; + border-right: 1px solid rgba(255,255,255,0.1); + + &:last-child { + border-right: none; + } + + .label { + font-size: 12px; + color: #888; + display: block; + margin-bottom: 5px; + } + + .value { + font-size: 14px; + color: #fff; + font-weight: bold; + } + } +} + +.desc { + margin-bottom: 20px; + + .section-title { + font-size: 16px; + color: #fff; + margin-bottom: 10px; + display: block; + border-left: 3px solid #00f0ff; + padding-left: 10px; + } + + .text { + font-size: 14px; + color: #ccc; + line-height: 1.6; + } +} + +.feature-item { + margin-bottom: 15px; + + .f-title { + font-size: 15px; + color: #00f0ff; + margin-bottom: 5px; + display: block; + } + + .f-desc { + font-size: 13px; + color: #bbb; + } +} + +.bottom-bar { + background: #111; + padding: 10px 20px; + border-top: 1px solid rgba(255,255,255,0.1); + + .btn-container { + display: flex; + gap: 15px; + } + + .btn-cart, .btn-buy { + flex: 1; + border: none; + color: #fff; + font-size: 16px; + height: 44px; + line-height: 44px; + border-radius: 22px; + margin: 0; + } + + .btn-cart { + background: #333; + } + + .btn-buy { + background: #00b96b; + } +} + +.safe-area-bottom { + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/miniprogram/src/pages/goods/detail.tsx b/miniprogram/src/pages/goods/detail.tsx new file mode 100644 index 0000000..af85362 --- /dev/null +++ b/miniprogram/src/pages/goods/detail.tsx @@ -0,0 +1,80 @@ +import { View, Text, Image, ScrollView, Button } from '@tarojs/components' +import Taro, { useRouter, useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getConfigDetail } from '../../api' +import './detail.scss' + +export default function Detail() { + const router = useRouter() + const { id } = router.params + const [product, setProduct] = useState(null) + + useLoad(() => { + if (id) fetchDetail(id) + }) + + const fetchDetail = async (id) => { + try { + const res = await getConfigDetail(id) + setProduct(res) + } catch (err) { + console.error(err) + } + } + + const buyNow = () => { + if (!product) return + Taro.navigateTo({ + url: `/pages/order/checkout?id=${product.id}&quantity=1` + }) + } + + if (!product) return Loading... + + return ( + + + + + + {product.name} + ¥{product.price} + + + + 芯片 + {product.chip_type} + + + Flash + {product.flash_size}MB + + + RAM + {product.ram_size}MB + + + + + 产品介绍 + {product.description} + + + {product.features && product.features.map((f, idx) => ( + + • {f.title} + {f.description} + + ))} + + + + + + + + + + + ) +} diff --git a/miniprogram/src/pages/index/index.config.ts b/miniprogram/src/pages/index/index.config.ts new file mode 100644 index 0000000..30ef2aa --- /dev/null +++ b/miniprogram/src/pages/index/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: 'Quant Speed Market' +}) diff --git a/miniprogram/src/pages/index/index.scss b/miniprogram/src/pages/index/index.scss new file mode 100644 index 0000000..5affc1b --- /dev/null +++ b/miniprogram/src/pages/index/index.scss @@ -0,0 +1,160 @@ +.page-container { + min-height: 100vh; + background-color: #000; + color: #fff; + padding: 20px; +} + +.header { + text-align: center; + margin-bottom: 40px; + padding-top: 40px; + + .logo-placeholder { + font-size: 24px; + font-weight: bold; + color: #00f0ff; + margin-bottom: 20px; + letter-spacing: 2px; + } + + .title-container { + margin-bottom: 20px; + display: flex; + justify-content: center; + align-items: center; + } + + .title-text { + font-size: 32px; + font-weight: bold; + color: #00f0ff; + text-shadow: 0 0 10px rgba(0, 240, 255, 0.5); + } + + .cursor { + font-size: 32px; + color: #fff; + margin-left: 5px; + animation: blink 1s infinite; + } + + .subtitle { + color: #aaa; + font-size: 14px; + line-height: 1.6; + display: block; + padding: 0 20px; + } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.product-scroll { + width: 100%; + white-space: nowrap; +} + +.product-list { + display: flex; + padding-bottom: 20px; +} + +.card { + display: inline-block; + width: 280px; + background: linear-gradient(135deg, rgba(31,31,31,0.9), rgba(42,42,42,0.9)); + border-radius: 12px; + margin-right: 20px; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.1); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + + &-cover { + height: 180px; + background: #222; + + .card-img { + width: 100%; + height: 100%; + } + } + + &-body { + padding: 16px; + } + + &-title { + font-size: 18px; + font-weight: bold; + color: #00f0ff; + display: block; + margin-bottom: 8px; + white-space: normal; + } + + &-desc { + font-size: 12px; + color: #bbb; + display: block; + margin-bottom: 12px; + height: 36px; + overflow: hidden; + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .tags { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; + + .tag { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + + &.cyan { + color: cyan; + background: rgba(0,255,255,0.1); + border: 1px solid cyan; + } + + &.blue { + color: blue; + background: rgba(0,0,255,0.1); + border: 1px solid blue; + } + } + } + + &-footer { + display: flex; + justify-content: space-between; + align-items: center; + + .price { + font-size: 20px; + color: #00b96b; + font-weight: bold; + } + + .btn-arrow { + width: 30px; + height: 30px; + border-radius: 50%; + background: #00b96b; + color: #fff; + display: flex; + justify-content: center; + align-items: center; + font-size: 16px; + } + } +} diff --git a/miniprogram/src/pages/index/index.tsx b/miniprogram/src/pages/index/index.tsx new file mode 100644 index 0000000..60d3b67 --- /dev/null +++ b/miniprogram/src/pages/index/index.tsx @@ -0,0 +1,110 @@ +import { View, Text, Image, ScrollView, Button } from '@tarojs/components' +import Taro, { useLoad } from '@tarojs/taro' +import { useState, useEffect } from 'react' +import { getConfigs } from '../../api' +import './index.scss' + +export default function Index() { + const [products, setProducts] = useState([]) + const [typedText, setTypedText] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const fullText = "未来已来 AI 核心驱动" + + useLoad(() => { + fetchProducts() + }) + + useEffect(() => { + let i = 0 + const interval = setInterval(() => { + i++ + setTypedText(fullText.slice(0, i)) + if (i >= fullText.length) clearInterval(interval) + }, 150) + return () => clearInterval(interval) + }, []) + + const fetchProducts = async () => { + setLoading(true) + setError('') + try { + const res: any = await getConfigs() + console.log('Configs fetched:', res) + // Adapt to different API response structures + const list = Array.isArray(res) ? res : (res.results || res.data || []) + setProducts(list) + } catch (err: any) { + console.error('Fetch error:', err) + setError(err.errMsg || '加载失败,请检查网络') + } finally { + setLoading(false) + } + } + + const goToDetail = (id: number) => { + Taro.navigateTo({ url: `/pages/goods/detail?id=${id}` }) + } + + return ( + + + + QUANT SPEED + + + + {typedText} + | + + 量迹 AI 硬件为您提供最强大的边缘计算能力 + + + {loading ? ( + + 正在加载硬件配置... + + ) : error ? ( + + {error} + + + ) : products.length === 0 ? ( + + 暂无硬件产品 + + ) : ( + + + {products.map((item) => ( + goToDetail(item.id)}> + + {item.static_image_url ? ( + + ) : ( + + 🚀 + + )} + + + {item.name} + {item.description} + + {item.chip_type} + {item.has_camera && Camera} + {item.has_microphone && Mic} + + + ¥{item.price} + + + + + ))} + + + )} + + ) +} diff --git a/miniprogram/src/pages/order/checkout.config.ts b/miniprogram/src/pages/order/checkout.config.ts new file mode 100644 index 0000000..bdde364 --- /dev/null +++ b/miniprogram/src/pages/order/checkout.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '确认订单' +}) diff --git a/miniprogram/src/pages/order/checkout.scss b/miniprogram/src/pages/order/checkout.scss new file mode 100644 index 0000000..9fdc11c --- /dev/null +++ b/miniprogram/src/pages/order/checkout.scss @@ -0,0 +1,74 @@ +.page-container { + min-height: 100vh; + background-color: #f7f8fa; + padding: 15px; + padding-bottom: 80px; +} + +.section { + background: #fff; + border-radius: 12px; + padding: 20px; + margin-bottom: 15px; + box-shadow: 0 2px 8px rgba(0,0,0,0.02); +} + +.address-section { + min-height: 80px; + display: flex; + flex-direction: column; + justify-content: center; + + .row { + margin-bottom: 8px; + .name { font-size: 16px; font-weight: bold; margin-right: 10px; } + .phone { font-size: 14px; color: #666; } + } + .addr { font-size: 14px; color: #333; line-height: 1.4; } + + .placeholder-container { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } + .placeholder { font-size: 16px; color: #00b96b; } +} + +.product-section { + .p-name { font-size: 16px; font-weight: 500; margin-bottom: 10px; display: block; } + .row { display: flex; justify-content: space-between; align-items: center; } + .p-price { font-size: 16px; color: #333; } + .p-qty { font-size: 14px; color: #999; } + + .divider { height: 1px; background: #eee; margin: 15px 0; } + + .total-row { + .total-price { font-size: 20px; color: #ff4d4f; font-weight: bold; } + } +} + +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + padding: 10px 20px; + border-top: 1px solid #eee; + + .btn-submit { + background: #00b96b; + color: #fff; + border-radius: 22px; + border: none; + font-size: 16px; + height: 44px; + line-height: 44px; + } +} + +.safe-area-bottom { + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/miniprogram/src/pages/order/checkout.tsx b/miniprogram/src/pages/order/checkout.tsx new file mode 100644 index 0000000..4d14116 --- /dev/null +++ b/miniprogram/src/pages/order/checkout.tsx @@ -0,0 +1,98 @@ +import { View, Text, Button } from '@tarojs/components' +import Taro, { useRouter, useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getConfigDetail, createOrder } from '../../api' +import './checkout.scss' + +export default function Checkout() { + const router = useRouter() + const { id, quantity } = router.params + const [product, setProduct] = useState(null) + const [address, setAddress] = useState(null) + const [contact, setContact] = useState({ name: '', phone: '' }) + + useLoad(async () => { + if (id) { + const res = await getConfigDetail(Number(id)) + setProduct(res) + } + }) + + const chooseAddress = async () => { + try { + const res = await Taro.chooseAddress() + setAddress(res) + setContact({ name: res.userName, phone: res.telNumber }) + } catch (e) { + Taro.showToast({ title: '需要授权获取地址', icon: 'none' }) + } + } + + const submitOrder = async () => { + if (!address) { + Taro.showToast({ title: '请选择收货地址', icon: 'none' }) + return + } + + try { + Taro.showLoading({ title: '正在下单...' }) + const orderData = { + goodid: product.id, + quantity: Number(quantity || 1), + customer_name: contact.name, + phone_number: contact.phone, + shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`, + // ref_code: Taro.getStorageSync('ref_code') + } + + const res = await createOrder(orderData) + Taro.hideLoading() + + if (res.order_id) { + Taro.redirectTo({ url: `/pages/order/payment?id=${res.order_id}` }) + } + } catch (err) { + Taro.hideLoading() + console.error(err) + } + } + + if (!product) return Loading... + + return ( + + + {address ? ( + + + {contact.name} + {contact.phone} + + {address.provinceName}{address.cityName}{address.countyName}{address.detailInfo} + + ) : ( + + + 选择收货地址 + + )} + + + + {product.name} + + ¥{product.price} + x {quantity} + + + + 合计 + ¥{(product.price * (Number(quantity) || 1)).toFixed(2)} + + + + + + + + ) +} diff --git a/miniprogram/src/pages/order/list.config.ts b/miniprogram/src/pages/order/list.config.ts new file mode 100644 index 0000000..92e9eb5 --- /dev/null +++ b/miniprogram/src/pages/order/list.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '我的订单' +}) diff --git a/miniprogram/src/pages/order/list.scss b/miniprogram/src/pages/order/list.scss new file mode 100644 index 0000000..0a220ad --- /dev/null +++ b/miniprogram/src/pages/order/list.scss @@ -0,0 +1,72 @@ +.page-container { + min-height: 100vh; + background-color: #f7f8fa; + padding: 15px; +} + +.card { + background: #fff; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + + .header { + display: flex; + justify-content: space-between; + border-bottom: 1px solid #f5f5f5; + padding-bottom: 10px; + margin-bottom: 10px; + font-size: 12px; + color: #999; + + .status { + &.pending { color: #faad14; } + &.paid { color: #52c41a; } + } + } + + .body { + display: flex; + + .img { + width: 80px; + height: 80px; + border-radius: 4px; + background: #eee; + margin-right: 10px; + } + + .info { + flex: 1; + .name { font-size: 14px; color: #333; display: block; margin-bottom: 5px; } + .qty { font-size: 12px; color: #999; } + } + + .price { + font-size: 16px; + font-weight: bold; + color: #333; + } + } + + .footer { + display: flex; + justify-content: flex-end; + margin-top: 10px; + + .btn-pay { + border: 1px solid #00b96b; + color: #00b96b; + padding: 4px 12px; + border-radius: 14px; + font-size: 12px; + } + } +} + +.empty { + text-align: center; + padding-top: 100px; + color: #999; + font-size: 14px; +} diff --git a/miniprogram/src/pages/order/list.tsx b/miniprogram/src/pages/order/list.tsx new file mode 100644 index 0000000..4b93e35 --- /dev/null +++ b/miniprogram/src/pages/order/list.tsx @@ -0,0 +1,55 @@ +import { View, Text, ScrollView, Image } from '@tarojs/components' +import Taro, { useDidShow } from '@tarojs/taro' +import { useState } from 'react' +import { getMyOrders } from '../../api' +import './list.scss' + +export default function OrderList() { + const [orders, setOrders] = useState([]) + + useDidShow(() => { + fetchOrders() + }) + + const fetchOrders = async () => { + try { + const res = await getMyOrders() + setOrders(Array.isArray(res) ? res : []) + } catch (err) { + console.error(err) + } + } + + const goPay = (id) => Taro.navigateTo({ url: `/pages/order/payment?id=${id}` }) + + return ( + + + {orders.map(order => ( + + + {order.created_at?.substring(0, 10)} + + {order.status === 'pending' ? '待支付' : order.status === 'paid' ? '已支付' : order.status} + + + + + + {order.config_name} + x {order.quantity} + + + ¥{order.total_price} + + + + {order.status === 'pending' && goPay(order.id)}>去支付} + + + ))} + {orders.length === 0 && 暂无订单} + + + ) +} diff --git a/miniprogram/src/pages/order/payment.config.ts b/miniprogram/src/pages/order/payment.config.ts new file mode 100644 index 0000000..6ffad57 --- /dev/null +++ b/miniprogram/src/pages/order/payment.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '订单支付' +}) diff --git a/miniprogram/src/pages/order/payment.scss b/miniprogram/src/pages/order/payment.scss new file mode 100644 index 0000000..e0f03e3 --- /dev/null +++ b/miniprogram/src/pages/order/payment.scss @@ -0,0 +1,63 @@ +.page-container { + min-height: 100vh; + background-color: #f7f8fa; + padding: 20px; +} + +.status-header { + text-align: center; + padding: 40px 0; + + .amount { + font-size: 40px; + font-weight: bold; + color: #333; + display: block; + } + + .desc { + font-size: 14px; + color: #999; + margin-top: 10px; + display: block; + } +} + +.info-card { + background: #fff; + border-radius: 12px; + padding: 20px; + margin-bottom: 30px; + + .row { + display: flex; + justify-content: space-between; + margin-bottom: 15px; + + &:last-child { margin-bottom: 0; } + + .label { color: #999; font-size: 14px; } + .value { color: #333; font-size: 14px; font-weight: 500; } + } +} + +.btn-area { + .btn-pay { + background: #07c160; + color: #fff; + border: none; + border-radius: 8px; + font-size: 16px; + height: 48px; + line-height: 48px; + + &:active { + opacity: 0.8; + } + } +} + +.safe-area-bottom { + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/miniprogram/src/pages/order/payment.tsx b/miniprogram/src/pages/order/payment.tsx new file mode 100644 index 0000000..a15bdc6 --- /dev/null +++ b/miniprogram/src/pages/order/payment.tsx @@ -0,0 +1,80 @@ +import { View, Text, Button } from '@tarojs/components' +import Taro, { useRouter, useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getOrder, prepayMiniprogram } from '../../api' +import './payment.scss' + +export default function Payment() { + const router = useRouter() + const { id } = router.params + const [order, setOrder] = useState(null) + const [loading, setLoading] = useState(false) + + useLoad(async () => { + if (id) { + try { + const res = await getOrder(Number(id)) + setOrder(res) + } catch (e) { + console.error(e) + } + } + }) + + const handlePay = async () => { + if (!order) return + setLoading(true) + try { + const params = await prepayMiniprogram(order.id) + + await Taro.requestPayment({ + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign + }) + + Taro.showToast({ title: '支付成功', icon: 'success' }) + setTimeout(() => { + Taro.redirectTo({ url: '/pages/order/list' }) + }, 1500) + + } catch (err: any) { + console.error(err) + if (err.errMsg && err.errMsg.indexOf('cancel') > -1) { + Taro.showToast({ title: '取消支付', icon: 'none' }) + } else { + Taro.showToast({ title: '支付失败', icon: 'none' }) + } + } finally { + setLoading(false) + } + } + + if (!order) return Loading... + + return ( + + + ¥{order.total_price} + 订单待支付 + + + + + 订单编号 + {order.out_trade_no || order.id} + + + 商品名称 + {order.config_name} x {order.quantity} + + + + + + + + ) +} diff --git a/miniprogram/src/pages/services/detail.config.ts b/miniprogram/src/pages/services/detail.config.ts new file mode 100644 index 0000000..4e3dedb --- /dev/null +++ b/miniprogram/src/pages/services/detail.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '服务详情' +}) diff --git a/miniprogram/src/pages/services/detail.scss b/miniprogram/src/pages/services/detail.scss new file mode 100644 index 0000000..b8aca8f --- /dev/null +++ b/miniprogram/src/pages/services/detail.scss @@ -0,0 +1,245 @@ +.page-container { + padding: 20px; + background-color: #000; + min-height: 100vh; + box-sizing: border-box; + padding-bottom: 120px; // Space for bottom bar +} + +.detail-header { + margin-bottom: 30px; + + .title { + color: #fff; + font-size: 48px; + font-weight: bold; + display: block; + margin-bottom: 15px; + } + + .desc { + color: #888; + font-size: 28px; + line-height: 1.5; + } +} + +.info-card { + background: rgba(255,255,255,0.03); + padding: 30px; + border-radius: 16px; + border: 1px solid rgba(255,255,255,0.08); + margin-bottom: 40px; + + .card-title { + color: #fff; + font-size: 32px; + margin-bottom: 20px; + display: flex; + align-items: center; + + .bar { + width: 6px; + height: 24px; + border-radius: 3px; + margin-right: 15px; + } + } + + .info-item { + margin-bottom: 15px; + display: flex; + flex-direction: column; + + .label { + color: #888; + font-size: 24px; + margin-bottom: 5px; + } + .value { + color: #fff; + font-size: 28px; + font-weight: 500; + } + } +} + +.detail-image-box { + width: 100%; + background: #111; + border-radius: 12px; + overflow: hidden; + margin-bottom: 40px; + + .detail-img { + width: 100%; + display: block; + } +} + +.price-card { + background: #1f1f1f; + padding: 30px; + border-radius: 16px; + margin-bottom: 40px; + + .price-title { + color: #fff; + font-size: 32px; + margin-bottom: 15px; + display: block; + } + + .price-row { + display: flex; + align-items: baseline; + margin-bottom: 20px; + + .price-val { + font-size: 48px; + font-weight: bold; + } + .price-unit { + color: #888; + font-size: 24px; + margin-left: 10px; + } + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; + + .tag { + padding: 8px 16px; + border-radius: 8px; + font-size: 24px; + backdrop-filter: blur(4px); + } + } +} + +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #1f1f1f; + padding: 20px 30px; + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: space-between; + align-items: center; + z-index: 100; + + .btn-buy { + width: 100%; + height: 90px; + line-height: 90px; + font-weight: bold; + font-size: 32px; + border-radius: 45px; + color: #000; + } +} + +// Modal Styles +.modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 900; +} + +.modal-content { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #2c2c2c; + border-top-left-radius: 24px; + border-top-right-radius: 24px; + padding: 40px; + padding-bottom: calc(40px + constant(safe-area-inset-bottom)); + padding-bottom: calc(40px + env(safe-area-inset-bottom)); + z-index: 1000; + transform: translateY(100%); + transition: transform 0.3s ease-out; + + &.visible { + transform: translateY(0); + } + + .modal-title { + color: #fff; + font-size: 36px; + font-weight: bold; + margin-bottom: 10px; + display: block; + } + + .modal-desc { + color: #999; + font-size: 26px; + margin-bottom: 30px; + display: block; + } + + .form-item { + margin-bottom: 25px; + + .label { + color: #ccc; + font-size: 28px; + margin-bottom: 10px; + display: block; + } + + .input { + background: #1f1f1f; + border: 1px solid #444; + border-radius: 12px; + height: 80px; + padding: 0 20px; + color: #fff; + font-size: 28px; + } + + .textarea { + background: #1f1f1f; + border: 1px solid #444; + border-radius: 12px; + padding: 20px; + color: #fff; + font-size: 28px; + height: 160px; + width: 100%; + box-sizing: border-box; + } + } + + .modal-actions { + display: flex; + gap: 20px; + margin-top: 40px; + + .btn-cancel { + flex: 1; + background: #444; + color: #fff; + } + + .btn-submit { + flex: 2; + background: #00b96b; + color: #fff; + } + } +} diff --git a/miniprogram/src/pages/services/detail.tsx b/miniprogram/src/pages/services/detail.tsx new file mode 100644 index 0000000..48e5533 --- /dev/null +++ b/miniprogram/src/pages/services/detail.tsx @@ -0,0 +1,155 @@ +import { View, Text, Image, Button, Input, Textarea } from '@tarojs/components' +import Taro, { useLoad } from '@tarojs/taro' +import { useState } from 'react' +import { getServiceDetail, createServiceOrder } from '../../api' +import './detail.scss' + +export default function ServiceDetail() { + const [service, setService] = useState(null) + const [loading, setLoading] = useState(true) + const [modalVisible, setModalVisible] = useState(false) + const [formData, setFormData] = useState({ + customer_name: '', + company_name: '', + phone_number: '', + email: '', + requirements: '' + }) + + useLoad((options) => { + if (options.id) { + fetchDetail(options.id) + } + }) + + const fetchDetail = async (id: string) => { + try { + const res: any = await getServiceDetail(Number(id)) + setService(res) + } catch (err) { + console.error(err) + Taro.showToast({ title: '加载失败', icon: 'none' }) + } finally { + setLoading(false) + } + } + + const handleInput = (key: string, value: string) => { + setFormData(prev => ({ ...prev, [key]: value })) + } + + const handleSubmit = async () => { + if (!formData.customer_name || !formData.phone_number) { + Taro.showToast({ title: '请填写姓名和电话', icon: 'none' }) + return + } + + try { + Taro.showLoading({ title: '提交中...' }) + await createServiceOrder({ + service: service.id, + ...formData, + ref_code: Taro.getStorageSync('ref_code') || '' + }) + Taro.hideLoading() + setModalVisible(false) + Taro.showModal({ + title: '提交成功', + content: '需求已提交,我们的销售顾问将尽快与您联系!', + showCancel: false + }) + } catch (err) { + Taro.hideLoading() + Taro.showToast({ title: '提交失败', icon: 'none' }) + } + } + + if (loading) return Loading... + if (!service) return Service not found + + return ( + + + {service.title} + {service.description} + + + + + + 服务详情 + + + 交付周期 + {service.delivery_time || '待沟通'} + + + 交付内容 + {service.delivery_content || '根据需求定制'} + + + + {service.detail_image_url && ( + + + + )} + + + 服务报价 + + ¥{service.price} + / {service.unit} 起 + + + {service.features && service.features.split('\n').map((feat: string, i: number) => ( + + {feat} + + ))} + + + + + + + + {/* Modal Layer */} + {modalVisible && ( + setModalVisible(false)} /> + )} + + 咨询/购买 + 请填写您的联系方式,我们将尽快联系您 + + + 您的姓名 * + handleInput('customer_name', e.detail.value)} /> + + + 联系电话 * + handleInput('phone_number', e.detail.value)} /> + + + 需求描述 +