mi
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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='下单微信用户'),
|
||||
),
|
||||
]
|
||||
30
backend/shop/migrations/0017_withdrawal.py
Normal file
30
backend/shop/migrations/0017_withdrawal.py
Normal file
@@ -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': '提现管理',
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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):
|
||||
"""
|
||||
@@ -147,6 +227,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="")
|
||||
phone_number = models.CharField(max_length=20, verbose_name="联系电话", default="")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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'})
|
||||
|
||||
|
||||
Binary file not shown.
37
miniprogram/API.md
Normal file
37
miniprogram/API.md
Normal file
@@ -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 <token>`
|
||||
- **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`
|
||||
16
miniprogram/DEPLOY.md
Normal file
16
miniprogram/DEPLOY.md
Normal file
@@ -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.
|
||||
86
miniprogram/README.md
Normal file
86
miniprogram/README.md
Normal file
@@ -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
|
||||
9
miniprogram/babel.config.js
Normal file
9
miniprogram/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// babel.config.js
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', {
|
||||
framework: 'react',
|
||||
ts: true
|
||||
}]
|
||||
]
|
||||
}
|
||||
9
miniprogram/config/dev.js
Normal file
9
miniprogram/config/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
NODE_ENV: '"development"'
|
||||
},
|
||||
defineConstants: {
|
||||
},
|
||||
mini: {},
|
||||
h5: {}
|
||||
}
|
||||
75
miniprogram/config/index.js
Normal file
75
miniprogram/config/index.js
Normal file
@@ -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'))
|
||||
}
|
||||
18
miniprogram/config/prod.js
Normal file
18
miniprogram/config/prod.js
Normal file
@@ -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, [])
|
||||
* }
|
||||
*/
|
||||
}
|
||||
}
|
||||
22
miniprogram/e2e/home.spec.js
Normal file
22
miniprogram/e2e/home.spec.js
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
67
miniprogram/package.json
Normal file
67
miniprogram/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
63
miniprogram/project.config.json
Normal file
63
miniprogram/project.config.json
Normal file
@@ -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": {}
|
||||
}
|
||||
22
miniprogram/project.private.config.json
Normal file
22
miniprogram/project.private.config.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
30
miniprogram/src/api/index.ts
Normal file
30
miniprogram/src/api/index.ts
Normal file
@@ -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 } })
|
||||
66
miniprogram/src/app.config.ts
Normal file
66
miniprogram/src/app.config.ts
Normal file
@@ -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"
|
||||
]
|
||||
})
|
||||
10
miniprogram/src/app.scss
Normal file
10
miniprogram/src/app.scss
Normal file
@@ -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;
|
||||
}
|
||||
21
miniprogram/src/app.ts
Normal file
21
miniprogram/src/app.ts
Normal file
@@ -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<any>) {
|
||||
|
||||
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
|
||||
BIN
miniprogram/src/assets/cart.png
Normal file
BIN
miniprogram/src/assets/cart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 B |
BIN
miniprogram/src/assets/cart_active.png
Normal file
BIN
miniprogram/src/assets/cart_active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
BIN
miniprogram/src/assets/home.png
Normal file
BIN
miniprogram/src/assets/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 B |
BIN
miniprogram/src/assets/home_active.png
Normal file
BIN
miniprogram/src/assets/home_active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
BIN
miniprogram/src/assets/user.png
Normal file
BIN
miniprogram/src/assets/user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 B |
BIN
miniprogram/src/assets/user_active.png
Normal file
BIN
miniprogram/src/assets/user_active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
3
miniprogram/src/pages/ar/detail.config.ts
Normal file
3
miniprogram/src/pages/ar/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '体验详情'
|
||||
})
|
||||
52
miniprogram/src/pages/ar/detail.scss
Normal file
52
miniprogram/src/pages/ar/detail.scss
Normal file
@@ -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;
|
||||
}
|
||||
51
miniprogram/src/pages/ar/detail.tsx
Normal file
51
miniprogram/src/pages/ar/detail.tsx
Normal file
@@ -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<any>(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 <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<Text className='title'>{detail.title}</Text>
|
||||
<Text className='desc'>{detail.description}</Text>
|
||||
|
||||
<View className='ar-placeholder'>
|
||||
<Text className='icon'>📷</Text>
|
||||
<Text className='text'>AR 场景加载区域</Text>
|
||||
</View>
|
||||
|
||||
<Button className='btn-launch' onClick={handleLaunch}>进入沉浸模式</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/ar/index.config.ts
Normal file
3
miniprogram/src/pages/ar/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AR 体验馆'
|
||||
})
|
||||
111
miniprogram/src/pages/ar/index.scss
Normal file
111
miniprogram/src/pages/ar/index.scss
Normal file
@@ -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;
|
||||
}
|
||||
68
miniprogram/src/pages/ar/index.tsx
Normal file
68
miniprogram/src/pages/ar/index.tsx
Normal file
@@ -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<any[]>([])
|
||||
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 <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='bg-decoration' />
|
||||
|
||||
<View className='header'>
|
||||
<Text className='title'>AR <Text className='highlight'>UNIVERSE</Text></Text>
|
||||
<Text className='desc'>探索全息增强现实体验</Text>
|
||||
</View>
|
||||
|
||||
<View className='ar-grid'>
|
||||
{arList.length === 0 ? (
|
||||
<View style={{ width: '100%', textAlign: 'center', color: '#666', marginTop: 50 }}>
|
||||
<Text>暂无 AR 体验内容</Text>
|
||||
</View>
|
||||
) : (
|
||||
arList.map((item) => (
|
||||
<View key={item.id} className='ar-card' onClick={() => goDetail(item.id)}>
|
||||
<View className='cover-box'>
|
||||
{item.cover_image_url ? (
|
||||
<Image src={item.cover_image_url} className='cover-img' mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='placeholder-icon'>AR</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='content'>
|
||||
<Text className='item-title'>{item.title}</Text>
|
||||
<Text className='item-desc'>{item.description}</Text>
|
||||
<Button className='btn-start'>启动体验</Button>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/cart/cart.config.ts
Normal file
3
miniprogram/src/pages/cart/cart.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '购物车'
|
||||
})
|
||||
8
miniprogram/src/pages/cart/cart.scss
Normal file
8
miniprogram/src/pages/cart/cart.scss
Normal file
@@ -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; }
|
||||
12
miniprogram/src/pages/cart/cart.tsx
Normal file
12
miniprogram/src/pages/cart/cart.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import './cart.scss'
|
||||
|
||||
export default function Cart() {
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='empty'>
|
||||
<Text>购物车功能即将上线</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/goods/detail.config.ts
Normal file
3
miniprogram/src/pages/goods/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '商品详情'
|
||||
})
|
||||
138
miniprogram/src/pages/goods/detail.scss
Normal file
138
miniprogram/src/pages/goods/detail.scss
Normal file
@@ -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);
|
||||
}
|
||||
80
miniprogram/src/pages/goods/detail.tsx
Normal file
80
miniprogram/src/pages/goods/detail.tsx
Normal file
@@ -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<any>(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 <View className='loading'>Loading...</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='content'>
|
||||
<Image src={product.detail_image_url || product.static_image_url || 'https://via.placeholder.com/400x400'} mode='widthFix' className='detail-img' />
|
||||
|
||||
<View className='info-section'>
|
||||
<Text className='title'>{product.name}</Text>
|
||||
<Text className='price'>¥{product.price}</Text>
|
||||
|
||||
<View className='specs'>
|
||||
<View className='spec-item'>
|
||||
<Text className='label'>芯片</Text>
|
||||
<Text className='value'>{product.chip_type}</Text>
|
||||
</View>
|
||||
<View className='spec-item'>
|
||||
<Text className='label'>Flash</Text>
|
||||
<Text className='value'>{product.flash_size}MB</Text>
|
||||
</View>
|
||||
<View className='spec-item'>
|
||||
<Text className='label'>RAM</Text>
|
||||
<Text className='value'>{product.ram_size}MB</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='desc'>
|
||||
<Text className='section-title'>产品介绍</Text>
|
||||
<Text className='text'>{product.description}</Text>
|
||||
</View>
|
||||
|
||||
{product.features && product.features.map((f, idx) => (
|
||||
<View key={idx} className='feature-item'>
|
||||
<Text className='f-title'>• {f.title}</Text>
|
||||
<Text className='f-desc'>{f.description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className='bottom-bar safe-area-bottom'>
|
||||
<View className='btn-container'>
|
||||
<Button className='btn-cart' onClick={() => Taro.showToast({title: '加入购物车', icon:'none'})}>加入购物车</Button>
|
||||
<Button className='btn-buy' onClick={buyNow}>立即购买</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/index/index.config.ts
Normal file
3
miniprogram/src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'Quant Speed Market'
|
||||
})
|
||||
160
miniprogram/src/pages/index/index.scss
Normal file
160
miniprogram/src/pages/index/index.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
miniprogram/src/pages/index/index.tsx
Normal file
110
miniprogram/src/pages/index/index.tsx
Normal file
@@ -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<any[]>([])
|
||||
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 (
|
||||
<View className='page-container'>
|
||||
<View className='header'>
|
||||
<View className='logo-box'>
|
||||
<Text className='logo-text'>QUANT SPEED</Text>
|
||||
</View>
|
||||
|
||||
<View className='title-container'>
|
||||
<Text className='title-text'>{typedText}</Text>
|
||||
<Text className='cursor'>|</Text>
|
||||
</View>
|
||||
<Text className='subtitle'>量迹 AI 硬件为您提供最强大的边缘计算能力</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className='status-box'>
|
||||
<Text className='loading-text'>正在加载硬件配置...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='status-box'>
|
||||
<Text className='error-text'>{error}</Text>
|
||||
<Button className='btn-retry' onClick={fetchProducts}>重试</Button>
|
||||
</View>
|
||||
) : products.length === 0 ? (
|
||||
<View className='status-box'>
|
||||
<Text className='empty-text'>暂无硬件产品</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollX className='product-scroll' enableFlex>
|
||||
<View className='product-list'>
|
||||
{products.map((item) => (
|
||||
<View key={item.id} className='card' onClick={() => goToDetail(item.id)}>
|
||||
<View className='card-cover'>
|
||||
{item.static_image_url ? (
|
||||
<Image src={item.static_image_url} mode='aspectFill' className='card-img' />
|
||||
) : (
|
||||
<View className='placeholder-img'>
|
||||
<Text className='icon-rocket'>🚀</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='card-body'>
|
||||
<Text className='card-title'>{item.name}</Text>
|
||||
<Text className='card-desc'>{item.description}</Text>
|
||||
<View className='tags'>
|
||||
<View className='tag cyan'><Text>{item.chip_type}</Text></View>
|
||||
{item.has_camera && <View className='tag blue'><Text>Camera</Text></View>}
|
||||
{item.has_microphone && <View className='tag purple'><Text>Mic</Text></View>}
|
||||
</View>
|
||||
<View className='card-footer'>
|
||||
<Text className='price'>¥{item.price}</Text>
|
||||
<View className='btn-arrow'><Text>→</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/checkout.config.ts
Normal file
3
miniprogram/src/pages/order/checkout.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认订单'
|
||||
})
|
||||
74
miniprogram/src/pages/order/checkout.scss
Normal file
74
miniprogram/src/pages/order/checkout.scss
Normal file
@@ -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);
|
||||
}
|
||||
98
miniprogram/src/pages/order/checkout.tsx
Normal file
98
miniprogram/src/pages/order/checkout.tsx
Normal file
@@ -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<any>(null)
|
||||
const [address, setAddress] = useState<any>(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 <View>Loading...</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='section address-section' onClick={chooseAddress}>
|
||||
{address ? (
|
||||
<View>
|
||||
<View className='row'>
|
||||
<Text className='name'>{contact.name}</Text>
|
||||
<Text className='phone'>{contact.phone}</Text>
|
||||
</View>
|
||||
<Text className='addr'>{address.provinceName}{address.cityName}{address.countyName}{address.detailInfo}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='placeholder-container'>
|
||||
<Text className='placeholder'>+ 选择收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='section product-section'>
|
||||
<Text className='p-name'>{product.name}</Text>
|
||||
<View className='row'>
|
||||
<Text className='p-price'>¥{product.price}</Text>
|
||||
<Text className='p-qty'>x {quantity}</Text>
|
||||
</View>
|
||||
<View className='divider' />
|
||||
<View className='row total-row'>
|
||||
<Text>合计</Text>
|
||||
<Text className='total-price'>¥{(product.price * (Number(quantity) || 1)).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='bottom-bar safe-area-bottom'>
|
||||
<Button className='btn-submit' onClick={submitOrder}>提交订单</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/list.config.ts
Normal file
3
miniprogram/src/pages/order/list.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的订单'
|
||||
})
|
||||
72
miniprogram/src/pages/order/list.scss
Normal file
72
miniprogram/src/pages/order/list.scss
Normal file
@@ -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;
|
||||
}
|
||||
55
miniprogram/src/pages/order/list.tsx
Normal file
55
miniprogram/src/pages/order/list.tsx
Normal file
@@ -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<any[]>([])
|
||||
|
||||
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 (
|
||||
<View className='page-container'>
|
||||
<ScrollView scrollY className='list'>
|
||||
{orders.map(order => (
|
||||
<View key={order.id} className='card'>
|
||||
<View className='header'>
|
||||
<Text className='time'>{order.created_at?.substring(0, 10)}</Text>
|
||||
<Text className={`status ${order.status}`}>
|
||||
{order.status === 'pending' ? '待支付' : order.status === 'paid' ? '已支付' : order.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='body'>
|
||||
<Image src={order.config_image || 'https://via.placeholder.com/80'} className='img' mode='aspectFill' />
|
||||
<View className='info'>
|
||||
<Text className='name'>{order.config_name}</Text>
|
||||
<Text className='qty'>x {order.quantity}</Text>
|
||||
</View>
|
||||
<View className='price'>
|
||||
<Text>¥{order.total_price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='footer'>
|
||||
{order.status === 'pending' && <View className='btn-pay' onClick={() => goPay(order.id)}>去支付</View>}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{orders.length === 0 && <View className='empty'>暂无订单</View>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/order/payment.config.ts
Normal file
3
miniprogram/src/pages/order/payment.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单支付'
|
||||
})
|
||||
63
miniprogram/src/pages/order/payment.scss
Normal file
63
miniprogram/src/pages/order/payment.scss
Normal file
@@ -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);
|
||||
}
|
||||
80
miniprogram/src/pages/order/payment.tsx
Normal file
80
miniprogram/src/pages/order/payment.tsx
Normal file
@@ -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<any>(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 <View>Loading...</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='status-header'>
|
||||
<Text className='amount'>¥{order.total_price}</Text>
|
||||
<Text className='desc'>订单待支付</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='row'>
|
||||
<Text className='label'>订单编号</Text>
|
||||
<Text className='value'>{order.out_trade_no || order.id}</Text>
|
||||
</View>
|
||||
<View className='row'>
|
||||
<Text className='label'>商品名称</Text>
|
||||
<Text className='value'>{order.config_name} x {order.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='btn-area safe-area-bottom'>
|
||||
<Button className='btn-pay' onClick={handlePay} loading={loading}>微信支付</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/detail.config.ts
Normal file
3
miniprogram/src/pages/services/detail.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '服务详情'
|
||||
})
|
||||
245
miniprogram/src/pages/services/detail.scss
Normal file
245
miniprogram/src/pages/services/detail.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
155
miniprogram/src/pages/services/detail.tsx
Normal file
155
miniprogram/src/pages/services/detail.tsx
Normal file
@@ -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<any>(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 <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
if (!service) return <View className='page-container'><Text style={{color:'#fff'}}>Service not found</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='detail-header'>
|
||||
<Text className='title'>{service.title}</Text>
|
||||
<Text className='desc'>{service.description}</Text>
|
||||
</View>
|
||||
|
||||
<View className='info-card'>
|
||||
<View className='card-title'>
|
||||
<View className='bar' style={{ background: service.color }} />
|
||||
<Text>服务详情</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付周期</Text>
|
||||
<Text className='value'>{service.delivery_time || '待沟通'}</Text>
|
||||
</View>
|
||||
<View className='info-item'>
|
||||
<Text className='label'>交付内容</Text>
|
||||
<Text className='value'>{service.delivery_content || '根据需求定制'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{service.detail_image_url && (
|
||||
<View className='detail-image-box' style={{ boxShadow: `0 10px 40px ${service.color}22` }}>
|
||||
<Image src={service.detail_image_url} className='detail-img' mode='widthFix' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='price-card'>
|
||||
<Text className='price-title'>服务报价</Text>
|
||||
<View className='price-row'>
|
||||
<Text className='price-val' style={{ color: service.color }}>¥{service.price}</Text>
|
||||
<Text className='price-unit'>/ {service.unit} 起</Text>
|
||||
</View>
|
||||
<View className='tags'>
|
||||
{service.features && service.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='tag' style={{
|
||||
background: `${service.color}11`,
|
||||
color: service.color,
|
||||
border: `1px solid ${service.color}66`
|
||||
}}>
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='bottom-bar'>
|
||||
<Button
|
||||
className='btn-buy'
|
||||
style={{ background: service.color }}
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
立即咨询 / 购买
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Modal Layer */}
|
||||
{modalVisible && (
|
||||
<View className='modal-mask' onClick={() => setModalVisible(false)} />
|
||||
)}
|
||||
<View className={`modal-content ${modalVisible ? 'visible' : ''}`}>
|
||||
<Text className='modal-title'>咨询/购买</Text>
|
||||
<Text className='modal-desc'>请填写您的联系方式,我们将尽快联系您</Text>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='label'>您的姓名 *</Text>
|
||||
<Input className='input' placeholder='请输入姓名' value={formData.customer_name} onInput={(e) => handleInput('customer_name', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>联系电话 *</Text>
|
||||
<Input className='input' type='number' placeholder='请输入电话' value={formData.phone_number} onInput={(e) => handleInput('phone_number', e.detail.value)} />
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='label'>需求描述</Text>
|
||||
<Textarea className='textarea' placeholder='请简单描述您的需求...' value={formData.requirements} onInput={(e) => handleInput('requirements', e.detail.value)} />
|
||||
</View>
|
||||
|
||||
<View className='modal-actions'>
|
||||
<Button className='btn-cancel' onClick={() => setModalVisible(false)}>取消</Button>
|
||||
<Button className='btn-submit' onClick={handleSubmit}>提交需求</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/services/index.config.ts
Normal file
3
miniprogram/src/pages/services/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AI 全栈解决方案'
|
||||
})
|
||||
186
miniprogram/src/pages/services/index.scss
Normal file
186
miniprogram/src/pages/services/index.scss
Normal file
@@ -0,0 +1,186 @@
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background-color: #000;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
|
||||
.highlight {
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 10px rgba(0,240,255,0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.hud-corner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&.tl { top: 0; left: 0; border-top: 2px solid; border-left: 2px solid; }
|
||||
&.br { bottom: 0; right: 0; border-bottom: 2px solid; border-right: 2px solid; }
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.icon-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
|
||||
.icon-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #ccc;
|
||||
font-size: 26px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-more {
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
|
||||
&:after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.process-section {
|
||||
margin-top: 60px;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,185,107,0.05) 100%);
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 40px;
|
||||
display: block;
|
||||
text-shadow: 0 0 10px rgba(0, 185, 107, 0.5);
|
||||
}
|
||||
|
||||
.process-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
width: 48%; // 2 columns
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.step-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 185, 107, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
color: #00b96b;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
miniprogram/src/pages/services/index.tsx
Normal file
102
miniprogram/src/pages/services/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { getServices } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function ServicesIndex() {
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchServices()
|
||||
})
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res: any = await getServices()
|
||||
// Adapt API response if needed (res.data vs res)
|
||||
setServices(res.results || res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/services/detail?id=${id}` })
|
||||
}
|
||||
|
||||
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header'>
|
||||
<Text className='title'>AI 全栈<Text className='highlight'>解决方案</Text></Text>
|
||||
<Text className='subtitle'>从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。</Text>
|
||||
</View>
|
||||
|
||||
<View className='service-grid'>
|
||||
{services.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='service-card'
|
||||
style={{
|
||||
border: `1px solid ${item.color}33`,
|
||||
boxShadow: `0 0 20px ${item.color}11`
|
||||
}}
|
||||
onClick={() => goDetail(item.id)}
|
||||
>
|
||||
<View className='hud-corner tl' style={{ borderColor: item.color }} />
|
||||
<View className='hud-corner br' style={{ borderColor: item.color }} />
|
||||
|
||||
<View className='card-header'>
|
||||
<View className='icon-box' style={{ background: `${item.color}22` }}>
|
||||
{item.icon_url ? (
|
||||
<Image src={item.icon_url} className='icon-img' mode='aspectFit' />
|
||||
) : (
|
||||
<View className='icon-placeholder' style={{ background: item.color }} />
|
||||
)}
|
||||
</View>
|
||||
<Text className='title'>{item.title}</Text>
|
||||
</View>
|
||||
|
||||
<Text className='description'>{item.description}</Text>
|
||||
|
||||
<View className='features'>
|
||||
{item.features && item.features.split('\n').map((feat: string, i: number) => (
|
||||
<View key={i} className='feature-item' style={{ color: item.color }}>
|
||||
<View className='dot' style={{ background: item.color }} />
|
||||
<Text>{feat}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button className='btn-more'>了解更多 {'>'}</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='process-section'>
|
||||
<Text className='section-title'>服务流程</Text>
|
||||
<View className='process-steps'>
|
||||
{[
|
||||
{ title: '需求分析', desc: '深度沟通需求', id: 1 },
|
||||
{ title: '数据准备', desc: '高效数据处理', id: 2 },
|
||||
{ title: '模型训练', desc: '高性能算力', id: 3 },
|
||||
{ title: '测试验证', desc: '多维精度测试', id: 4 },
|
||||
{ title: '私有化部署', desc: '全栈落地部署', id: 5 }
|
||||
].map((step) => (
|
||||
<View key={step.id} className='step-item'>
|
||||
<View className='step-icon'><Text>{step.id}</Text></View>
|
||||
<Text className='step-title'>{step.title}</Text>
|
||||
<Text className='step-desc'>{step.desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/pages/user/index.config.ts
Normal file
3
miniprogram/src/pages/user/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '个人中心'
|
||||
})
|
||||
53
miniprogram/src/pages/user/index.scss
Normal file
53
miniprogram/src/pages/user/index.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
margin-right: 15px;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: #fff;
|
||||
|
||||
.item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.arrow { color: #ccc; }
|
||||
|
||||
.btn-contact {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
miniprogram/src/pages/user/index.tsx
Normal file
46
miniprogram/src/pages/user/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import './index.scss'
|
||||
|
||||
export default function UserIndex() {
|
||||
const [userInfo, setUserInfo] = useState<any>(null)
|
||||
|
||||
useDidShow(() => {
|
||||
const info = Taro.getStorageSync('userInfo')
|
||||
if (info) setUserInfo(info)
|
||||
})
|
||||
|
||||
const goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
|
||||
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
|
||||
const login = () => {
|
||||
// Trigger login again if needed
|
||||
Taro.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header'>
|
||||
<Image src={userInfo?.avatar_url || 'https://via.placeholder.com/100'} className='avatar' />
|
||||
<Text className='nickname'>{userInfo?.nickname || '未登录'}</Text>
|
||||
{!userInfo && <Button size='mini' onClick={login}>点击登录</Button>}
|
||||
</View>
|
||||
|
||||
<View className='menu'>
|
||||
<View className='item' onClick={goOrders}>
|
||||
<Text>我的订单</Text>
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
<View className='item' onClick={goDistributor}>
|
||||
<Text>分销中心</Text>
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
<View className='item'>
|
||||
<Text>联系客服</Text>
|
||||
<Button openType='contact' className='btn-contact' />
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/index.config.ts
Normal file
3
miniprogram/src/subpackages/distributor/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '分销中心'
|
||||
})
|
||||
68
miniprogram/src/subpackages/distributor/index.scss
Normal file
68
miniprogram/src/subpackages/distributor/index.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f7f8fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
background: linear-gradient(135deg, #00b96b, #009456);
|
||||
border-radius: 12px;
|
||||
padding: 30px 20px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.label { font-size: 14px; opacity: 0.8; display: block; margin-bottom: 10px; }
|
||||
.amount { font-size: 40px; font-weight: bold; display: block; margin-bottom: 20px; }
|
||||
|
||||
.btn-withdraw {
|
||||
background: #fff;
|
||||
color: #00b96b;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
padding: 0 30px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-right: 1px solid #eee;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
|
||||
.val { font-size: 18px; font-weight: bold; color: #333; display: block; margin-bottom: 5px; }
|
||||
.lbl { font-size: 12px; color: #999; }
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0 20px;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.arrow { color: #ccc; }
|
||||
}
|
||||
}
|
||||
76
miniprogram/src/subpackages/distributor/index.tsx
Normal file
76
miniprogram/src/subpackages/distributor/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorInfo } from '../../api'
|
||||
import './index.scss'
|
||||
|
||||
export default function DistributorIndex() {
|
||||
const [info, setInfo] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useDidShow(() => {
|
||||
fetchInfo()
|
||||
})
|
||||
|
||||
const fetchInfo = async () => {
|
||||
try {
|
||||
const res = await distributorInfo()
|
||||
setInfo(res)
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404) {
|
||||
// Not registered
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/register' })
|
||||
} else {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
|
||||
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
|
||||
|
||||
if (loading) return <View>Loading...</View>
|
||||
if (!info) return <View>Error</View>
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='header-card'>
|
||||
<Text className='label'>可提现余额</Text>
|
||||
<Text className='amount'>¥{info.withdrawable_balance}</Text>
|
||||
<Button className='btn-withdraw' onClick={goWithdraw}>提现</Button>
|
||||
</View>
|
||||
|
||||
<View className='stats-grid'>
|
||||
<View className='item'>
|
||||
<Text className='val'>¥{info.total_earnings}</Text>
|
||||
<Text className='lbl'>累计收益</Text>
|
||||
</View>
|
||||
<View className='item'>
|
||||
<Text className='val'>Lv.{info.level}</Text>
|
||||
<Text className='lbl'>当前等级</Text>
|
||||
</View>
|
||||
<View className='item'>
|
||||
<Text className='val'>{(Number(info.commission_rate) * 100).toFixed(1)}%</Text>
|
||||
<Text className='lbl'>分佣比例</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='menu-list'>
|
||||
<View className='menu-item' onClick={goInvite}>
|
||||
<Text>推广二维码</Text>
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
<View className='menu-item'>
|
||||
<Text>我的团队</Text>
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
<View className='menu-item'>
|
||||
<Text>提现记录</Text>
|
||||
<Text className='arrow'>></Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
miniprogram/src/subpackages/distributor/invite.config.ts
Normal file
3
miniprogram/src/subpackages/distributor/invite.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '推广邀请'
|
||||
})
|
||||
43
miniprogram/src/subpackages/distributor/invite.scss
Normal file
43
miniprogram/src/subpackages/distributor/invite.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
.page-container {
|
||||
padding: 30px;
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
margin-top: 40px;
|
||||
|
||||
.qr-img {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #eee;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #666;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
margin-top: 60px;
|
||||
width: 100%;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
}
|
||||
57
miniprogram/src/subpackages/distributor/invite.tsx
Normal file
57
miniprogram/src/subpackages/distributor/invite.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { View, Text, Image, Button } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorInvite } from '../../api'
|
||||
import './invite.scss'
|
||||
|
||||
export default function Invite() {
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useLoad(() => {
|
||||
fetchQr()
|
||||
})
|
||||
|
||||
const fetchQr = async () => {
|
||||
try {
|
||||
const res: any = await distributorInvite()
|
||||
setQrCode(res.qr_code_url)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
Taro.showToast({ title: '获取二维码失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = () => {
|
||||
if (!qrCode) return
|
||||
Taro.downloadFile({
|
||||
url: qrCode,
|
||||
success: (res) => {
|
||||
Taro.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => Taro.showToast({ title: '已保存', icon: 'success' }),
|
||||
fail: () => Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='qr-card'>
|
||||
{loading ? (
|
||||
<View className='qr-img' style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Image src={qrCode} className='qr-img' mode='aspectFit' />
|
||||
)}
|
||||
<Text className='tip'>扫码加入我的团队{'\n'}一起推广赚佣金</Text>
|
||||
</View>
|
||||
|
||||
<Button className='btn-save' onClick={saveImage} disabled={!qrCode}>保存二维码</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '申请分销员'
|
||||
})
|
||||
42
miniprogram/src/subpackages/distributor/register.scss
Normal file
42
miniprogram/src/subpackages/distributor/register.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f7f8fa;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px 30px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: block;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.btn-register {
|
||||
background: #00b96b;
|
||||
color: #fff;
|
||||
border-radius: 22px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
32
miniprogram/src/subpackages/distributor/register.tsx
Normal file
32
miniprogram/src/subpackages/distributor/register.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { distributorRegister } from '../../api'
|
||||
import './register.scss'
|
||||
|
||||
export default function Register() {
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
await distributorRegister({})
|
||||
Taro.showToast({ title: '申请已提交', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/index' })
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
if (err.data?.error === 'Already registered') {
|
||||
Taro.redirectTo({ url: '/subpackages/distributor/index' })
|
||||
} else {
|
||||
Taro.showToast({ title: '申请失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='card'>
|
||||
<Text className='title'>加入我们</Text>
|
||||
<Text className='desc'>成为分销员,分享赚取佣金</Text>
|
||||
<Button className='btn-register' onClick={handleRegister}>立即申请</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '申请提现'
|
||||
})
|
||||
57
miniprogram/src/subpackages/distributor/withdraw.scss
Normal file
57
miniprogram/src/subpackages/distributor/withdraw.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
.page-container {
|
||||
padding: 30px;
|
||||
background-color: #f8f8f8;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
|
||||
.label {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.symbol {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 60px;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-tip {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
|
||||
.all {
|
||||
color: #576b95;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
margin-top: 60px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
73
miniprogram/src/subpackages/distributor/withdraw.tsx
Normal file
73
miniprogram/src/subpackages/distributor/withdraw.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { View, Text, Button, Input } from '@tarojs/components'
|
||||
import Taro, { useLoad } from '@tarojs/taro'
|
||||
import { useState } from 'react'
|
||||
import { distributorInfo, distributorWithdraw } from '../../api'
|
||||
import './withdraw.scss'
|
||||
|
||||
export default function Withdraw() {
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [amount, setAmount] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useLoad(() => {
|
||||
fetchInfo()
|
||||
})
|
||||
|
||||
const fetchInfo = async () => {
|
||||
try {
|
||||
const res: any = await distributorInfo()
|
||||
setBalance(Number(res.withdrawable_balance))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
const val = Number(amount)
|
||||
if (!val || val <= 0) {
|
||||
Taro.showToast({ title: '请输入有效金额', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (val > balance) {
|
||||
Taro.showToast({ title: '余额不足', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await distributorWithdraw(val)
|
||||
Taro.showToast({ title: '申请已提交', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack()
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '提现失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='page-container'>
|
||||
<View className='card'>
|
||||
<Text className='label'>提现金额</Text>
|
||||
<View className='input-box'>
|
||||
<Text className='symbol'>¥</Text>
|
||||
<Input
|
||||
className='input'
|
||||
type='digit'
|
||||
value={amount}
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
placeholder='0.00'
|
||||
/>
|
||||
</View>
|
||||
<View className='balance-tip'>
|
||||
<Text>可提现余额 ¥{balance.toFixed(2)}</Text>
|
||||
<Text className='all' onClick={() => setAmount(balance.toString())}>全部提现</Text>
|
||||
</View>
|
||||
|
||||
<Button className='btn-submit' onClick={handleWithdraw} loading={loading}>确认提现</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
9
miniprogram/src/utils/format.test.ts
Normal file
9
miniprogram/src/utils/format.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { formatPrice } from './format'
|
||||
|
||||
describe('formatPrice', () => {
|
||||
test('formats correctly', () => {
|
||||
expect(formatPrice(100)).toBe('¥100.00')
|
||||
expect(formatPrice(0)).toBe('¥0.00')
|
||||
expect(formatPrice(99.9)).toBe('¥99.90')
|
||||
})
|
||||
})
|
||||
3
miniprogram/src/utils/format.ts
Normal file
3
miniprogram/src/utils/format.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const formatPrice = (price: number) => {
|
||||
return `¥${Number(price).toFixed(2)}`
|
||||
}
|
||||
76
miniprogram/src/utils/request.ts
Normal file
76
miniprogram/src/utils/request.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:8000/api'
|
||||
|
||||
export const request = async (options: Taro.request.Option) => {
|
||||
const token = Taro.getStorageSync('token')
|
||||
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
}
|
||||
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Ensure URL is clean
|
||||
let url = options.url
|
||||
if (!url.startsWith('http')) {
|
||||
url = BASE_URL + (url.startsWith('/') ? url : '/' + url)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await Taro.request({
|
||||
...options,
|
||||
url: url,
|
||||
header: header,
|
||||
})
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
// Token expired
|
||||
Taro.removeStorageSync('token')
|
||||
// Optional: Auto re-login logic could go here
|
||||
Taro.showToast({ title: '登录已过期,请重试', icon: 'none' })
|
||||
// For user experience, maybe trigger login
|
||||
return Promise.reject(res)
|
||||
}
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
const errMsg = res.data?.error || res.data?.detail || '请求失败'
|
||||
Taro.showToast({ title: errMsg, icon: 'none' })
|
||||
return Promise.reject(res)
|
||||
}
|
||||
|
||||
return res.data
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '网络错误', icon: 'none' })
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
export const login = async () => {
|
||||
try {
|
||||
const { code } = await Taro.login()
|
||||
if (!code) throw new Error('wx.login failed')
|
||||
|
||||
const res = await Taro.request({
|
||||
url: `${BASE_URL}/wechat/login/`,
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
})
|
||||
|
||||
if (res.statusCode === 200 && res.data.token) {
|
||||
Taro.setStorageSync('token', res.data.token)
|
||||
Taro.setStorageSync('openid', res.data.openid)
|
||||
// Save other info if needed
|
||||
return res.data
|
||||
} else {
|
||||
throw new Error(res.data.error || 'Login failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error', err)
|
||||
// Don't toast here to avoid spamming if called automatically
|
||||
throw err
|
||||
}
|
||||
}
|
||||
37
miniprogram/tsconfig.json
Normal file
37
miniprogram/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"module": "commonjs",
|
||||
"removeComments": false,
|
||||
"preserveConstEnums": true,
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitAny": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./src",
|
||||
"./types",
|
||||
"./config"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"compileOnSave": false
|
||||
}
|
||||
Reference in New Issue
Block a user