This commit is contained in:
jeremygan2021
2026-02-11 01:31:21 +08:00
parent 61afc52ac2
commit 2d090cd0f4
97 changed files with 3661 additions and 4 deletions

Binary file not shown.

View File

@@ -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='下单微信用户'),
),
]

View 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': '提现管理',
},
),
]

View File

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

View File

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

View File

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

View File

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

37
miniprogram/API.md Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1,9 @@
// babel.config.js
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true
}]
]
}

View File

@@ -0,0 +1,9 @@
module.exports = {
env: {
NODE_ENV: '"development"'
},
defineConstants: {
},
mini: {},
h5: {}
}

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

View 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, [])
* }
*/
}
}

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

View 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": {}
}

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '体验详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AR 体验馆'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '购物车'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商品详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'Quant Speed Market'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '确认订单'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的订单'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单支付'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '服务详情'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AI 全栈解决方案'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '个人中心'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销中心'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '推广邀请'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '申请分销员'
})

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '申请提现'
})

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

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

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

View File

@@ -0,0 +1,3 @@
export const formatPrice = (price: number) => {
return `¥${Number(price).toFixed(2)}`
}

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