fix: 3D Show

This commit is contained in:
xiaoma
2026-02-02 19:10:34 +08:00
commit b8024da3dc
61 changed files with 4123 additions and 0 deletions

252
.gitignore vendored Normal file
View File

@@ -0,0 +1,252 @@
# Django
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
media/
# Django 迁移文件
*/migrations/__pycache__/
*/migrations/*.pyc
# Django 静态文件
staticfiles/
static/
# Python
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Python 虚拟环境
venv/
env/
ENV/
env.bak/
venv.bak/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# 前端构建文件
dist/
build/
*.map
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Windows
Thumbs.db
ehthumbs.db
*.stackdump
# 大文件和媒体文件
*.mp4
*.mp3
*.avi
*.mov
*.wmv
*.flv
*.mkv
*.wav
*.flac
*.aac
*.wma
*.m4a
*.m4v
*.3gp
*.3g2
*.asf
*.rm
*.rmvb
*.vob
*.mpg
*.mpeg
*.m2v
*.m4v
*.svi
*.3gpp
*.3gpp2
# 图片文件(保留必要的,忽略大图片)
*.psd
*.ai
*.eps
*.raw
*.cr2
*.nef
*.orf
*.sr2
*.tiff
*.tif
*.bmp
*.ico
# 保留 PNG、JPG、JPEG、SVG 用于网站显示
# *.png
# *.jpg
# *.jpeg
# *.svg
# 3D模型文件大文件
*.obj
*.mtl
*.fbx
*.dae
*.3ds
*.max
*.ma
*.mb
*.blend
*.c4d
*.lwo
*.lws
*.skp
*.x3d
*.x3db
*.x3dv
*.wrl
*.wrz
*.ply
*.stl
*.stp
*.step
*.igs
*.iges
# 压缩文件
*.zip
*.rar
*.7z
*.tar
*.gz
*.bz2
*.xz
*.tar.gz
*.tar.bz2
*.tar.xz
*.tgz
*.tbz2
*.txz
# 文档文件(大文件)
*.pdf
*.doc
*.docx
*.xls
*.xlsx
*.ppt
*.pptx
*.odt
*.ods
*.odp
# 数据库文件
*.sql
*.sqlite
*.sqlite3
*.db
*.mdb
*.accdb
# 备份文件
*.bak
*.backup
*.old
*.orig
*.tmp
*.temp
*.swp
*.swo
# 日志文件
*.log
*.log.*
logs/
# 缓存文件
.cache/
*.cache
*.tmp
# 配置文件(敏感信息)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Docker
.dockerignore
# Git
.git/
# 其他大文件
*.iso
*.dmg
*.img
*.vmdk
*.vdi
*.vhd
*.vhdx
*.qcow
*.qcow2
*.ova
*.ovf
# 前端特定忽略
frontend/dist/
frontend/build/
frontend/node_modules/
# 后端特定忽略
backend/db.sqlite3
backend/__pycache__/
backend/*.pyc
backend/media/
backend/static/
backend/venv/
backend/env/
# 项目特定的大文件路径
frontend/public/3d*/
frontend/public/*.obj
frontend/public/*.mtl
frontend/dist/3d*/
frontend/dist/*.obj
frontend/dist/*.mtl

212
README.md Normal file
View File

@@ -0,0 +1,212 @@
# 量极AI硬件商城
一个基于React和Django的AI硬件在线商城系统提供硬件配置展示、订单管理和支付功能。
## 🚀 项目概述
量极AI硬件商城是一个全栈Web应用程序专注于AI硬件产品的在线销售。系统采用前后端分离架构前端使用React + Vite + Ant Design后端使用Django REST Framework。
## 📋 功能特性
### 前端功能
- 🛍️ 硬件配置展示和选择
- 🛒 购物车功能
- 📋 订单创建和管理
- 💳 支付流程集成
- 🔗 推广码支持
- 📱 响应式设计
### 后端功能
- 🏪 产品配置管理
- 📦 订单处理
- 💰 支付接口
- 👥 用户管理
- 📊 数据统计
## 🛠️ 技术栈
### 前端技术
- **React 19** - 现代化UI库
- **Vite** - 快速构建工具
- **Ant Design** - 企业级UI组件库
- **React Router** - 路由管理
- **Axios** - HTTP客户端
### 后端技术
- **Django 6.0** - Python Web框架
- **Django REST Framework** - RESTful API
- **PostgreSQL** - 数据库
- **CORS Headers** - 跨域支持
## 📁 项目结构
```
Quant-Speed_ai_hardware/
├── frontend/ # React前端应用
│ ├── src/
│ │ ├── components/ # React组件
│ │ │ └── HardwareShop.jsx
│ │ ├── App.jsx # 主应用组件
│ │ ├── api.js # API接口封装
│ │ └── main.jsx # 应用入口
│ ├── package.json # 前端依赖配置
│ └── vite.config.js # Vite配置
├── backend/ # Django后端应用
│ ├── config/ # Django配置
│ │ ├── settings.py # 主配置文件
│ │ ├── urls.py # URL路由配置
│ │ └── wsgi.py # WSGI配置
│ ├── shop/ # 商城应用
│ │ ├── models.py # 数据模型
│ │ ├── views.py # 视图函数
│ │ ├── serializers.py # 序列化器
│ │ └── urls.py # 应用路由
│ ├── manage.py # Django管理脚本
│ └── populate_db.py # 数据库初始化脚本
└── README.md
```
## 🚀 快速开始
### 环境要求
- Node.js 18+
- Python 3.8+
- PostgreSQL 12+
### 前端安装
```bash
# 进入前端目录
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
```
### 后端安装
```bash
# 进入后端目录
cd backend
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows
venv\Scripts\activate
# macOS/Linux
source venv/bin/activate
# 安装依赖
pip install django djangorestframework django-cors-headers psycopg2-binary
# 数据库配置
# 编辑 config/settings.py 中的数据库配置
# 运行数据库迁移
python manage.py migrate
# 创建超级用户
python manage.py createsuperuser
# 启动开发服务器
python manage.py runserver
```
### 数据库初始化
```bash
# 运行数据库填充脚本
python populate_db.py
```
### admin账户:
jeremygan2021
qweasdzxc1
## 🔧 配置说明
### 前端配置
- **Vite配置**: `frontend/vite.config.js`
- **环境变量**: 支持 `.env` 文件配置
### 后端配置
- **Django设置**: `backend/config/settings.py`
- **数据库**: PostgreSQL配置
- **CORS**: 跨域请求配置
- **国际化**: 中文支持
## 📡 API接口
### 硬件配置接口
- `GET /api/configs/` - 获取硬件配置列表
- `GET /api/configs/{id}/` - 获取特定配置详情
### 订单接口
- `POST /api/orders/` - 创建订单
- `GET /api/orders/{id}/` - 获取订单详情
- `POST /api/orders/{id}/pay/` - 订单支付
### 支付接口
- `POST /api/payments/initiate/` - 发起支付
- `POST /api/payments/confirm/` - 确认支付
## 🎯 使用说明
### 推广码功能
系统支持URL推广码参数格式`?ref=推广码`
### 支付流程
1. 选择硬件配置
2. 填写订单信息
3. 发起支付请求
4. 确认支付结果
5. 订单完成
## 🔒 安全说明
- 生产环境请修改 `SECRET_KEY`
- 配置HTTPS证书
- 设置适当的CORS白名单
- 定期备份数据库
## 🐛 常见问题
### 跨域问题
确保后端CORS配置正确开发环境可设置为允许所有来源。
### 数据库连接失败
检查PostgreSQL服务状态和连接配置。
### 前端构建失败
检查Node.js版本和依赖包完整性。
## 📞 联系方式
如有问题或建议,请通过以下方式联系:
- 邮箱support@Quant-Speed-ai.com
- 电话400-123-4567
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
## 🙏 致谢
感谢以下开源项目的支持:
- [React](https://reactjs.org/)
- [Django](https://www.djangoproject.com/)
- [Ant Design](https://ant.design/)
- [Vite](https://vitejs.dev/)
---
**⭐ 如果这个项目对您有帮助,请给我们一个星标!**

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Use an official Python runtime as a parent image
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /app
# Install system dependencies (needed for psycopg2 and others)
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install python dependencies
COPY requirements.txt /app/
RUN pip install --upgrade pip && pip install -r requirements.txt
# Copy project
COPY . /app/
# Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

16
backend/config/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

179
backend/config/settings.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 6.0.1.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-9hwh_v44(3n)61g)tiwkvm1k0h&5c+u=68&z*!$e0ujpd-6^1o'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'unfold', # django-unfold必须在admin之前
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'drf_spectacular', # Swagger文档生成
'drf_spectacular_sidecar',
'shop',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ALLOW_ALL_ORIGINS = True
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'market',
'USER': 'market',
'PASSWORD': '123market',
'HOST': '6.6.6.66',
'PORT': '5432',
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# 静态文件配置
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Django REST Framework配置
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': [],
'DEFAULT_PERMISSION_CLASSES': [],
}
# drf-spectacular配置
SPECTACULAR_SETTINGS = {
'TITLE': '科技公司产品购买API',
'DESCRIPTION': '科技公司产品购买官网的API文档',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': True,
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
'COMPONENT_SPLIT_REQUEST': True,
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
}
# django-unfold配置
UNFOLD = {
"SITE_TITLE": "科技公司产品管理",
"SITE_HEADER": "科技公司产品购买系统",
"SITE_URL": "/",
"COLORS": {
"primary": {
"50": "rgb(240 249 255)",
"100": "rgb(224 242 254)",
"200": "rgb(186 230 253)",
"300": "rgb(125 211 252)",
"400": "rgb(56 189 248)",
"500": "rgb(14 165 233)",
"600": "rgb(2 132 199)",
"700": "rgb(3 105 161)",
"800": "rgb(7 89 133)",
"900": "rgb(12 74 110)",
"950": "rgb(8 47 73)",
},
},
}

19
backend/config/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('shop.urls')),
# Swagger文档路由
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
# 静态文件配置(开发环境)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

16
backend/config/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

22
backend/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

52
backend/populate_db.py Normal file
View File

@@ -0,0 +1,52 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from shop.models import ESP32Config
def populate():
# 清除旧数据,避免重复累积
# 注意:在生产环境中慎用 delete
ESP32Config.objects.all().delete()
configs = [
{
"name": "AI小智 Mini款",
"chip_type": "ESP32-C3",
"flash_size": 4,
"ram_size": 1,
"has_camera": False,
"has_microphone": True,
"price": 150.00,
"description": "高性价比入门款,支持语音交互,小巧便携。"
},
{
"name": "AI小智 V2款 (舵机版)",
"chip_type": "ESP32-S3",
"flash_size": 8,
"ram_size": 2,
"has_camera": False,
"has_microphone": True,
"price": 188.00,
"description": "升级版性能,支持驱动舵机,适合机器人控制与运动交互。"
},
{
"name": "AI小智 V3款 (视觉版)",
"chip_type": "ESP32-S3",
"flash_size": 16,
"ram_size": 8,
"has_camera": True,
"has_microphone": True,
"price": 250.00,
"description": "旗舰视觉版配备摄像头与高性能计算单元支持视觉识别与复杂AI任务。"
}
]
for data in configs:
config = ESP32Config.objects.create(**data)
print(f"Created: {config.name} - ¥{config.price}")
if __name__ == '__main__':
populate()

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
asgiref==3.11.0
attrs==25.4.0
Django==6.0.1
django-cors-headers==4.9.0
django-unfold==0.77.1
djangorestframework==3.16.1
drf-spectacular==0.29.0
inflection==0.5.1
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
pillow==12.1.0
psycopg2-binary==2.9.11
PyYAML==6.0.3
qrcode==8.2
referencing==0.37.0
rpds-py==0.30.0
sqlparse==0.5.5
uritemplate==4.2.0

0
backend/shop/__init__.py Normal file
View File

186
backend/shop/admin.py Normal file
View File

@@ -0,0 +1,186 @@
from django.contrib import admin
from django.utils.html import format_html
from django.db.models import Sum
from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, ARService, ProductFeature
import qrcode
from io import BytesIO
import base64
# 自定义后台标题
admin.site.site_header = "量迹AI硬件销售管理后台"
admin.site.site_title = "量迹AI后台"
admin.site.index_title = "欢迎使用量迹AI管理系统"
class ProductFeatureInline(TabularInline):
model = ProductFeature
extra = 1
fields = ('title', 'description', 'icon_name', 'icon_image', 'icon_url', 'order')
@admin.register(WeChatPayConfig)
class WeChatPayConfigAdmin(ModelAdmin):
list_display = ('app_id', 'mch_id', 'is_active', 'notify_url')
list_filter = ('is_active',)
search_fields = ('app_id', 'mch_id')
fieldsets = (
('基本配置', {
'fields': ('app_id', 'mch_id', 'is_active')
}),
('安全配置', {
'fields': ('api_key', 'app_secret')
}),
('回调配置', {
'fields': ('notify_url',)
}),
)
@admin.register(ESP32Config)
class ESP32ConfigAdmin(ModelAdmin):
list_display = ('name', 'chip_type', 'price', 'has_camera', 'has_microphone')
list_filter = ('chip_type', 'has_camera')
search_fields = ('name', 'description')
inlines = [ProductFeatureInline]
fieldsets = (
('基本信息', {
'fields': ('name', 'price', 'description')
}),
('硬件参数', {
'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone')
}),
('详情页图片', {
'fields': ('detail_image', 'detail_image_url'),
'description': '图片上传和URL二选一优先使用URL'
}),
)
@admin.register(Service)
class ServiceAdmin(ModelAdmin):
list_display = ('title', 'created_at')
search_fields = ('title', 'description')
fieldsets = (
('基本信息', {
'fields': ('title', 'description', 'color')
}),
('价格与交付', {
'fields': ('price', 'unit', 'delivery_time', 'delivery_content')
}),
('图标', {
'fields': ('icon', 'icon_url'),
'description': '图标上传和URL二选一优先使用URL'
}),
('详情页图片', {
'fields': ('detail_image', 'detail_image_url'),
'description': '图片上传和URL二选一优先使用URL'
}),
('详细内容', {
'fields': ('features',)
}),
)
@admin.register(ARService)
class ARServiceAdmin(ModelAdmin):
list_display = ('title', 'created_at')
search_fields = ('title', 'description')
fieldsets = (
('基本信息', {
'fields': ('title', 'description')
}),
('封面/长图', {
'fields': ('cover_image', 'cover_image_url'),
'description': '图片上传和URL二选一优先使用URL'
}),
)
@admin.register(Salesperson)
class SalespersonAdmin(ModelAdmin):
list_display = ('name', 'code', 'total_sales', 'view_promotion_url')
search_fields = ('name', 'code')
readonly_fields = ('promotion_qr_code', 'promotion_url_display', 'total_sales_display')
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(
_total_sales=Sum('orders__total_price', default=0)
)
return queryset
@display(description="累计销售额 (已支付)", ordering='_total_sales')
def total_sales(self, obj):
# 仅计算已支付的订单
paid_sales = obj.orders.filter(status='paid').aggregate(total=Sum('total_price'))['total']
return f"¥{paid_sales or 0:.2f}"
def total_sales_display(self, obj):
return self.total_sales(obj)
total_sales_display.short_description = "累计销售额 (已支付)"
def promotion_url(self, obj):
# 假设前端部署在 localhost:5173生产环境需配置
base_url = "http://localhost:5173"
return f"{base_url}/?ref={obj.code}"
@display(description="推广链接")
def view_promotion_url(self, obj):
url = self.promotion_url(obj)
return format_html('<a href="{}" target="_blank" class="button">打开推广链接</a>', url)
def promotion_url_display(self, obj):
return self.promotion_url(obj)
promotion_url_display.short_description = "完整推广链接"
@display(description="推广二维码")
def promotion_qr_code(self, obj):
if not obj.code:
return "请先保存以生成二维码"
url = self.promotion_url(obj)
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
img_str = base64.b64encode(buffer.getvalue()).decode()
return format_html('<img src="data:image/png;base64,{}" width="200" height="200" class="qr-code" />', img_str)
fieldsets = (
('基本信息', {
'fields': ('name', 'code')
}),
('推广工具', {
'fields': ('promotion_url_display', 'promotion_qr_code')
}),
('业绩统计', {
'fields': ('total_sales_display',)
}),
)
@admin.register(Order)
class OrderAdmin(ModelAdmin):
list_display = ('id', 'customer_name', 'config', 'total_price', 'status', 'salesperson', 'created_at')
list_filter = ('status', 'salesperson', 'created_at')
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no')
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
fieldsets = (
('订单信息', {
'fields': ('config', 'quantity', 'total_price', 'status', 'created_at')
}),
('客户信息', {
'fields': ('customer_name', 'phone_number', 'shipping_address')
}),
('销售归属', {
'fields': ('salesperson',)
}),
('支付信息', {
'fields': ('wechat_trade_no',)
}),
)

5
backend/shop/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
name = 'shop'

View File

@@ -0,0 +1,50 @@
# Generated by Django 6.0.1 on 2026-02-02 04:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ESP32Config',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='配置名称')),
('chip_type', models.CharField(help_text='例如: ESP32-S3, ESP32-C3', max_length=50, verbose_name='芯片型号')),
('flash_size', models.IntegerField(default=4, verbose_name='Flash大小(MB)')),
('ram_size', models.IntegerField(default=2, verbose_name='PSRAM大小(MB)')),
('has_camera', models.BooleanField(default=False, verbose_name='是否包含摄像头')),
('has_microphone', models.BooleanField(default=False, verbose_name='是否包含麦克风')),
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')),
('description', models.TextField(blank=True, verbose_name='描述')),
],
options={
'verbose_name': '硬件配置',
'verbose_name_plural': '硬件配置列表',
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.IntegerField(default=1, verbose_name='数量')),
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='总价')),
('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
('wechat_trade_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信支付单号')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置')),
],
options={
'verbose_name': '订单',
'verbose_name_plural': '订单列表',
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-02-02 04:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='customer_name',
field=models.CharField(default='', max_length=100, verbose_name='收货人姓名'),
),
migrations.AddField(
model_name='order',
name='phone_number',
field=models.CharField(default='', max_length=20, verbose_name='联系电话'),
),
migrations.AddField(
model_name='order',
name='shipping_address',
field=models.TextField(default='', verbose_name='发货地址'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.1 on 2026-02-02 04:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0002_order_customer_name_order_phone_number_and_more'),
]
operations = [
migrations.CreateModel(
name='Salesperson',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name='销售员姓名')),
('code', models.CharField(help_text='唯一的推广标识码,如: zhangsan01', max_length=20, unique=True, verbose_name='推广码')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '销售员',
'verbose_name_plural': '销售员管理',
},
),
migrations.AlterModelOptions(
name='esp32config',
options={'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'},
),
migrations.AddField(
model_name='order',
name='salesperson',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.salesperson', verbose_name='所属销售员'),
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.10 on 2026-02-02 04:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0003_salesperson_alter_esp32config_options_and_more'),
]
operations = [
migrations.CreateModel(
name='WeChatPayConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('app_id', models.CharField(max_length=50, verbose_name='AppID')),
('mch_id', models.CharField(max_length=50, verbose_name='商户号(MchID)')),
('api_key', models.CharField(max_length=100, verbose_name='API密钥(Key)')),
('app_secret', models.CharField(blank=True, max_length=100, null=True, verbose_name='AppSecret')),
('notify_url', models.URLField(verbose_name='回调通知地址')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
],
options={
'verbose_name': '微信支付配置',
'verbose_name_plural': '微信支付配置',
},
),
migrations.AlterField(
model_name='esp32config',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='order',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='salesperson',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 6.0.1 on 2026-02-02 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more'),
]
operations = [
migrations.CreateModel(
name='Service',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='服务名称')),
('icon', models.ImageField(upload_to='services/icons/', verbose_name='图标')),
('description', models.TextField(verbose_name='简介')),
('features', models.TextField(help_text='每行一个特性', verbose_name='特性列表')),
('color', models.CharField(default='#00f0ff', max_length=20, verbose_name='主题色')),
('detail_image', models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': 'AI服务',
'verbose_name_plural': 'AI服务管理',
},
),
migrations.AlterField(
model_name='esp32config',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='order',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='salesperson',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='wechatpayconfig',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,58 @@
# Generated by Django 6.0.1 on 2026-02-02 05:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0005_service_alter_esp32config_id_alter_order_id_and_more'),
]
operations = [
migrations.CreateModel(
name='ARService',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='体验名称')),
('description', models.TextField(verbose_name='简介')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='ar/covers/', verbose_name='封面/长图 (上传)')),
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面/长图 (URL)')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': 'AR体验',
'verbose_name_plural': 'AR体验管理',
},
),
migrations.AddField(
model_name='esp32config',
name='detail_image',
field=models.ImageField(blank=True, null=True, upload_to='products/details/', verbose_name='详情页长图 (上传)'),
),
migrations.AddField(
model_name='esp32config',
name='detail_image_url',
field=models.URLField(blank=True, help_text='如果填写了URL将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
),
migrations.AddField(
model_name='service',
name='detail_image_url',
field=models.URLField(blank=True, null=True, verbose_name='详情页长图 (URL)'),
),
migrations.AddField(
model_name='service',
name='icon_url',
field=models.URLField(blank=True, null=True, verbose_name='图标 (URL)'),
),
migrations.AlterField(
model_name='service',
name='detail_image',
field=models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图 (上传)'),
),
migrations.AlterField(
model_name='service',
name='icon',
field=models.ImageField(blank=True, null=True, upload_to='services/icons/', verbose_name='图标 (上传)'),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 6.0.1 on 2026-02-02 06:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0006_arservice_esp32config_detail_image_and_more'),
]
operations = [
migrations.CreateModel(
name='ProductFeature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=50, verbose_name='特性标题')),
('description', models.TextField(verbose_name='特性描述')),
('icon_name', models.CharField(blank=True, help_text='例如: SafetyCertificate, Eye, Thunderbolt', max_length=50, null=True, verbose_name='Antd图标名称')),
('icon_image', models.ImageField(blank=True, null=True, upload_to='products/features/', verbose_name='特性图标 (上传)')),
('icon_url', models.URLField(blank=True, null=True, verbose_name='特性图标 (URL)')),
('order', models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='shop.esp32config', verbose_name='所属产品')),
],
options={
'verbose_name': '产品特性',
'verbose_name_plural': '产品特性',
'ordering': ['order'],
},
),
]

View File

@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-02-02 06:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0007_productfeature'),
]
operations = [
migrations.AddField(
model_name='service',
name='delivery_content',
field=models.TextField(blank=True, help_text='描述将交付给客户的具体成果', verbose_name='交付内容'),
),
migrations.AddField(
model_name='service',
name='delivery_time',
field=models.CharField(blank=True, help_text='例如3-5个工作日', max_length=50, verbose_name='预计交付周期'),
),
migrations.AddField(
model_name='service',
name='price',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='起步价格'),
),
migrations.AddField(
model_name='service',
name='unit',
field=models.CharField(default='', help_text='例如:次、小时、月、个', max_length=20, verbose_name='计费单位'),
),
migrations.CreateModel(
name='ServiceOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('customer_name', models.CharField(max_length=100, verbose_name='客户姓名')),
('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司名称')),
('phone_number', models.CharField(max_length=20, verbose_name='联系电话')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')),
('requirements', models.TextField(blank=True, verbose_name='具体需求描述')),
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='预估总价')),
('status', models.CharField(choices=[('pending', '待沟通/待支付'), ('processing', '服务进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.service', verbose_name='所选服务')),
],
options={
'verbose_name': '服务订单',
'verbose_name_plural': '服务订单列表',
},
),
]

View File

205
backend/shop/models.py Normal file
View File

@@ -0,0 +1,205 @@
from django.db import models
from django.utils.html import format_html
import qrcode
from io import BytesIO
import base64
class ESP32Config(models.Model):
"""
ESP32 硬件配置选项模型
用于定义可售卖的硬件参数
"""
name = models.CharField(max_length=100, verbose_name="配置名称")
chip_type = models.CharField(max_length=50, verbose_name="芯片型号", help_text="例如: ESP32-S3, ESP32-C3")
flash_size = models.IntegerField(verbose_name="Flash大小(MB)", default=4)
ram_size = models.IntegerField(verbose_name="PSRAM大小(MB)", default=2)
has_camera = models.BooleanField(default=False, verbose_name="是否包含摄像头")
has_microphone = models.BooleanField(default=False, verbose_name="是否包含麦克风")
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="价格")
description = models.TextField(verbose_name="描述", blank=True)
detail_image = models.ImageField(upload_to='products/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用URL")
def __str__(self):
return f"{self.name} - ¥{self.price}"
class Meta:
verbose_name = "硬件配置 (小智参数)"
verbose_name_plural = "硬件配置 (小智参数)"
class ProductFeature(models.Model):
"""
产品特性模型 (关联到具体硬件配置)
"""
product = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, related_name='features', verbose_name="所属产品")
title = models.CharField(max_length=50, verbose_name="特性标题")
description = models.TextField(verbose_name="特性描述")
icon_name = models.CharField(max_length=50, blank=True, null=True, verbose_name="Antd图标名称", help_text="例如: SafetyCertificate, Eye, Thunderbolt")
icon_image = models.ImageField(upload_to='products/features/', blank=True, null=True, verbose_name="特性图标 (上传)")
icon_url = models.URLField(blank=True, null=True, verbose_name="特性图标 (URL)")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
def __str__(self):
return f"{self.product.name} - {self.title}"
class Meta:
verbose_name = "产品特性"
verbose_name_plural = "产品特性"
ordering = ['order']
class Salesperson(models.Model):
"""
销售人员模型
"""
name = models.CharField(max_length=50, verbose_name="销售员姓名")
code = models.CharField(max_length=20, unique=True, verbose_name="推广码", help_text="唯一的推广标识码,如: zhangsan01")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def __str__(self):
return f"{self.name} ({self.code})"
class Meta:
verbose_name = "销售员"
verbose_name_plural = "销售员管理"
class WeChatPayConfig(models.Model):
"""
微信支付配置模型
"""
app_id = models.CharField(max_length=50, verbose_name="AppID")
mch_id = models.CharField(max_length=50, verbose_name="商户号(MchID)")
api_key = models.CharField(max_length=100, verbose_name="API密钥(Key)")
app_secret = models.CharField(max_length=100, verbose_name="AppSecret", blank=True, null=True)
notify_url = models.URLField(verbose_name="回调通知地址")
is_active = models.BooleanField(default=True, verbose_name="是否启用")
class Meta:
verbose_name = "微信支付配置"
verbose_name_plural = "微信支付配置"
def __str__(self):
return f"微信支付配置 ({'启用' if self.is_active else '禁用'})"
def save(self, *args, **kwargs):
# 确保只有一个启用的配置
if self.is_active:
WeChatPayConfig.objects.filter(is_active=True).exclude(id=self.id).update(is_active=False)
super().save(*args, **kwargs)
class Order(models.Model):
"""
订单模型
记录用户的购买请求和支付状态
"""
STATUS_CHOICES = (
('pending', '待支付'),
('paid', '已支付'),
('shipped', '已发货'),
('cancelled', '已取消'),
)
config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置")
quantity = models.IntegerField(default=1, verbose_name="数量")
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
# 销售归属
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员", related_name='orders')
# 用户信息
customer_name = models.CharField(max_length=100, verbose_name="收货人姓名", default="")
phone_number = models.CharField(max_length=20, verbose_name="联系电话", default="")
shipping_address = models.TextField(verbose_name="发货地址", default="")
# 微信支付相关字段
wechat_trade_no = models.CharField(max_length=100, blank=True, null=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"Order #{self.id} - {self.customer_name} - {self.status}"
class Meta:
verbose_name = "订单"
verbose_name_plural = "订单列表"
class Service(models.Model):
"""
AI服务项目模型
"""
title = models.CharField(max_length=100, verbose_name="服务名称")
icon = models.ImageField(upload_to='services/icons/', blank=True, null=True, verbose_name="图标 (上传)")
icon_url = models.URLField(blank=True, null=True, verbose_name="图标 (URL)")
description = models.TextField(verbose_name="简介")
features = models.TextField(verbose_name="特性列表", help_text="每行一个特性")
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="起步价格")
unit = models.CharField(max_length=20, default="", verbose_name="计费单位", help_text="例如:次、小时、月、个")
delivery_time = models.CharField(max_length=50, blank=True, verbose_name="预计交付周期", help_text="例如3-5个工作日")
delivery_content = models.TextField(blank=True, verbose_name="交付内容", help_text="描述将交付给客户的具体成果")
color = models.CharField(max_length=20, default="#00f0ff", verbose_name="主题色")
detail_image = models.ImageField(upload_to='services/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def __str__(self):
return self.title
class Meta:
verbose_name = "AI服务"
verbose_name_plural = "AI服务管理"
class ServiceOrder(models.Model):
"""
AI服务订单模型
"""
STATUS_CHOICES = (
('pending', '待沟通/待支付'),
('processing', '服务进行中'),
('completed', '已完成'),
('cancelled', '已取消'),
)
service = models.ForeignKey(Service, on_delete=models.CASCADE, verbose_name="所选服务")
customer_name = models.CharField(max_length=100, verbose_name="客户姓名")
company_name = models.CharField(max_length=100, blank=True, verbose_name="公司名称")
phone_number = models.CharField(max_length=20, verbose_name="联系电话")
email = models.EmailField(blank=True, verbose_name="电子邮箱")
requirements = models.TextField(verbose_name="具体需求描述", blank=True)
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="预估总价", default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
def __str__(self):
return f"{self.customer_name} - {self.service.title}"
class Meta:
verbose_name = "服务订单"
verbose_name_plural = "服务订单列表"
class ARService(models.Model):
"""
AR体验服务模型
"""
title = models.CharField(max_length=100, verbose_name="体验名称")
description = models.TextField(verbose_name="简介")
cover_image = models.ImageField(upload_to='ar/covers/', blank=True, null=True, verbose_name="封面/长图 (上传)")
cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面/长图 (URL)")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def __str__(self):
return self.title
class Meta:
verbose_name = "AR体验"
verbose_name_plural = "AR体验管理"

158
backend/shop/serializers.py Normal file
View File

@@ -0,0 +1,158 @@
from rest_framework import serializers
from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder
class ProductFeatureSerializer(serializers.ModelSerializer):
"""
产品特性序列化器
"""
display_icon = serializers.SerializerMethodField()
class Meta:
model = ProductFeature
fields = ['title', 'description', 'icon_name', 'display_icon', 'order']
def get_display_icon(self, obj):
if obj.icon_url:
return obj.icon_url
if obj.icon_image:
return obj.icon_image.url
return None
class ServiceSerializer(serializers.ModelSerializer):
"""
AI服务序列化器
"""
features_list = serializers.SerializerMethodField()
display_icon = serializers.SerializerMethodField()
display_detail_image = serializers.SerializerMethodField()
class Meta:
model = Service
fields = '__all__'
def get_features_list(self, obj):
if obj.features:
return [line.strip() for line in obj.features.split('\n') if line.strip()]
return []
def get_display_icon(self, obj):
if obj.icon_url:
return obj.icon_url
if obj.icon:
return obj.icon.url
return None
def get_display_detail_image(self, obj):
if obj.detail_image_url:
return obj.detail_image_url
if obj.detail_image:
return obj.detail_image.url
return None
class ServiceOrderSerializer(serializers.ModelSerializer):
"""
AI服务订单序列化器
"""
service_name = serializers.CharField(source='service.title', read_only=True)
# 接收前端传来的 ref_code
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
class Meta:
model = ServiceOrder
fields = ['id', 'service', 'service_name', 'customer_name', 'company_name',
'phone_number', 'email', 'requirements', 'total_price', 'status', 'created_at', 'ref_code']
read_only_fields = ['total_price', 'status', 'created_at']
def create(self, validated_data):
ref_code = validated_data.pop('ref_code', None)
service = validated_data.get('service')
# 默认设置预估总价为服务起步价
if service:
validated_data['total_price'] = service.price
# 尝试关联销售员
if ref_code:
try:
salesperson = Salesperson.objects.get(code=ref_code)
validated_data['salesperson'] = salesperson
except Salesperson.DoesNotExist:
pass
return super().create(validated_data)
class ARServiceSerializer(serializers.ModelSerializer):
"""
AR服务序列化器
"""
display_cover_image = serializers.SerializerMethodField()
class Meta:
model = ARService
fields = '__all__'
def get_display_cover_image(self, obj):
if obj.cover_image_url:
return obj.cover_image_url
if obj.cover_image:
return obj.cover_image.url
return None
class ESP32ConfigSerializer(serializers.ModelSerializer):
"""
ESP32配置序列化器
"""
display_detail_image = serializers.SerializerMethodField()
features = ProductFeatureSerializer(many=True, read_only=True)
class Meta:
model = ESP32Config
fields = '__all__'
def get_display_detail_image(self, obj):
if obj.detail_image_url:
return obj.detail_image_url
if obj.detail_image:
return obj.detail_image.url
return None
class OrderSerializer(serializers.ModelSerializer):
"""
订单序列化器
"""
config_name = serializers.CharField(source='config.name', read_only=True)
# 接收前端传来的 ref_code用于查找 Salesperson
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
class Meta:
model = Order
fields = ['id', 'config', 'config_name', 'quantity', 'total_price', 'status', 'created_at', 'wechat_trade_no',
'customer_name', 'phone_number', 'shipping_address', 'ref_code']
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at']
extra_kwargs = {
'customer_name': {'required': True},
'phone_number': {'required': True},
'shipping_address': {'required': True},
}
def create(self, validated_data):
"""
重写创建方法,自动计算总价并关联销售员
"""
config = validated_data.get('config')
quantity = validated_data.get('quantity', 1)
ref_code = validated_data.pop('ref_code', None)
validated_data['total_price'] = config.price * quantity
# 尝试关联销售员
if ref_code:
try:
salesperson = Salesperson.objects.get(code=ref_code)
validated_data['salesperson'] = salesperson
except Salesperson.DoesNotExist:
# 如果找不到对应的销售员,忽略该推广码,仍继续创建订单(算作自然流量)
pass
return super().create(validated_data)

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单查询 - 量迹AI硬件</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
}
button {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
#result {
margin-top: 30px;
}
.order-card {
border: 1px solid #eee;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
background-color: #fff;
}
.order-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 10px;
}
.status {
font-weight: bold;
}
.status-paid { color: green; }
.status-pending { color: orange; }
.status-shipped { color: blue; }
.error { color: red; text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>订单状态查询</h1>
<div class="form-group">
<label for="phone">请输入手机号码查询:</label>
<input type="tel" id="phone" placeholder="请输入下单时填写的手机号" required>
</div>
<button onclick="searchOrders()">查询订单</button>
<div id="result"></div>
</div>
<script>
async function searchOrders() {
const phone = document.getElementById('phone').value;
const resultDiv = document.getElementById('result');
if (!phone) {
alert('请输入手机号码');
return;
}
resultDiv.innerHTML = '<p style="text-align:center">查询中...</p>';
try {
const response = await fetch(`/api/orders/lookup/?phone=${phone}`);
const data = await response.json();
if (response.ok) {
if (data.length === 0) {
resultDiv.innerHTML = '<p class="error">未找到相关订单</p>';
return;
}
let html = '';
data.forEach(order => {
const statusMap = {
'pending': '待支付',
'paid': '已支付',
'shipped': '已发货',
'cancelled': '已取消'
};
const statusText = statusMap[order.status] || order.status;
const statusClass = `status-${order.status}`;
html += `
<div class="order-card">
<div class="order-header">
<span>订单号: ${order.id}</span>
<span class="status ${statusClass}">${statusText}</span>
</div>
<div>
<p><strong>商品:</strong> ${order.config_name || '未命名配置'}</p>
<p><strong>数量:</strong> ${order.quantity}</p>
<p><strong>总价:</strong> ¥${order.total_price}</p>
<p><strong>下单时间:</strong> ${new Date(order.created_at).toLocaleString()}</p>
</div>
</div>
`;
});
resultDiv.innerHTML = html;
} else {
resultDiv.innerHTML = `<p class="error">${data.error || '查询失败'}</p>`;
}
} catch (error) {
console.error('Error:', error);
resultDiv.innerHTML = '<p class="error">网络错误,请稍后重试</p>';
}
}
</script>
</body>
</html>

3
backend/shop/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

15
backend/shop/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ESP32ConfigViewSet, OrderViewSet, order_check_view, ServiceViewSet, ARServiceViewSet, ServiceOrderViewSet
router = DefaultRouter()
router.register(r'configs', ESP32ConfigViewSet)
router.register(r'orders', OrderViewSet)
router.register(r'services', ServiceViewSet)
router.register(r'ar', ARServiceViewSet)
router.register(r'service-orders', ServiceOrderViewSet)
urlpatterns = [
path('', include(router.urls)),
path('page/check-order/', order_check_view, name='check-order-page'),
]

145
backend/shop/views.py Normal file
View File

@@ -0,0 +1,145 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.shortcuts import render
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from .models import ESP32Config, Order, WeChatPayConfig, Service, ARService, ServiceOrder
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer
@extend_schema_view(
list=extend_schema(summary="获取AR服务列表", description="获取所有可用的AR服务"),
retrieve=extend_schema(summary="获取AR服务详情", description="获取指定AR服务的详细信息")
)
class ARServiceViewSet(viewsets.ReadOnlyModelViewSet):
"""
AR服务列表和详情
"""
queryset = ARService.objects.all().order_by('-created_at')
serializer_class = ARServiceSerializer
import uuid
import time
import hashlib
def order_check_view(request):
"""
订单查询页面视图
"""
return render(request, 'shop/order_check.html')
@extend_schema_view(
list=extend_schema(summary="获取AI服务列表", description="获取所有可用的AI服务"),
retrieve=extend_schema(summary="获取AI服务详情", description="获取指定AI服务的详细信息")
)
class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
"""
AI服务列表和详情
"""
queryset = Service.objects.all().order_by('-created_at')
serializer_class = ServiceSerializer
class ServiceOrderViewSet(viewsets.ModelViewSet):
"""
AI服务订单管理
"""
queryset = ServiceOrder.objects.all()
serializer_class = ServiceOrderSerializer
@extend_schema_view(
list=extend_schema(summary="获取ESP32配置列表", description="获取所有可用的ESP32硬件配置选项"),
retrieve=extend_schema(summary="获取ESP32配置详情", description="获取指定ESP32配置的详细信息")
)
class ESP32ConfigViewSet(viewsets.ReadOnlyModelViewSet):
"""
提供ESP32配置选项的列表和详情
"""
queryset = ESP32Config.objects.all()
serializer_class = ESP32ConfigSerializer
class OrderViewSet(viewsets.ModelViewSet):
"""
订单管理视图集
支持创建订单和查询订单状态
"""
queryset = Order.objects.all()
serializer_class = OrderSerializer
@action(detail=False, methods=['get'])
def lookup(self, request):
"""
根据电话号码查询订单状态
URL: /api/orders/lookup/?phone=13800138000
"""
phone = request.query_params.get('phone')
if not phone:
return Response({'error': '请提供电话号码'}, status=status.HTTP_400_BAD_REQUEST)
# 简单校验
orders = Order.objects.filter(phone_number=phone).order_by('-created_at')
serializer = self.get_serializer(orders, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def initiate_payment(self, request, pk=None):
"""
发起支付请求
获取微信支付配置并生成签名
"""
order = self.get_object()
if order.status == 'paid':
return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST)
# 获取微信支付配置
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
# 如果没有配置,为了演示方便,回退到模拟数据,或者报错
# 这里我们报错提示需要在后台配置
return Response({'error': '支付系统维护中 (未配置支付参数)'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# 构造支付参数
# 注意:实际生产环境必须在此处调用微信【统一下单】接口获取真实的 prepay_id
# 这里为了演示完整流程,我们使用配置中的参数生成合法的签名结构,但 prepay_id 是模拟的
app_id = wechat_config.app_id
timestamp = str(int(time.time()))
nonce_str = str(uuid.uuid4()).replace('-', '')
# 模拟的 prepay_id
prepay_id = f"wx{str(uuid.uuid4()).replace('-', '')}"
package = f"prepay_id={prepay_id}"
sign_type = 'MD5'
# 生成签名 (WeChat Pay V2 MD5 Signature)
# 签名步骤:
# 1. 设所有发送或者接收到的数据为集合M将集合M内非空参数值的参数按照参数名ASCII码从小到大排序字典序
# 2. 使用URL键值对的格式即key1=value1&key2=value2…拼接成字符串stringA
# 3. 在stringA最后拼接上key得到stringSignTemp字符串并对stringSignTemp进行MD5运算再将得到的字符串所有字符转换为大写
stringA = f"appId={app_id}&nonceStr={nonce_str}&package={package}&signType={sign_type}&timeStamp={timestamp}"
string_sign_temp = f"{stringA}&key={wechat_config.api_key}"
pay_sign = hashlib.md5(string_sign_temp.encode('utf-8')).hexdigest().upper()
payment_params = {
'appId': app_id,
'timeStamp': timestamp,
'nonceStr': nonce_str,
'package': package,
'signType': sign_type,
'paySign': pay_sign,
'orderId': order.id,
'amount': str(order.total_price)
}
return Response(payment_params)
@action(detail=True, methods=['post'])
def confirm_payment(self, request, pk=None):
"""
模拟支付成功回调/确认
"""
order = self.get_object()
order.status = 'paid'
order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}"
order.save()
return Response({'status': 'success', 'message': '支付成功'})

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
services:
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=market
- POSTGRES_USER=market
- POSTGRES_PASSWORD=123market
ports:
- "5432:5432"
backend:
build: ./backend
command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
volumes:
- ./backend:/app
ports:
- "8000:8000"
depends_on:
- db
environment:
- DB_NAME=market
- DB_USER=market
- DB_PASSWORD=123market
- DB_HOST=db
- DB_PORT=5432
frontend:
build: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
environment:
- VITE_API_URL=http://localhost:8000/api
depends_on:
- backend
volumes:
postgres_data:

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Use an official Node runtime as a parent image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Copy project files
COPY . .
# Expose the port the app runs on
EXPOSE 5173
# Start the application
CMD ["npm", "run", "dev", "--", "--host"]

16
frontend/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/liangji_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quant Speed</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"antd": "^6.2.2",
"axios": "^1.13.4",
"framer-motion": "^12.29.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"three": "^0.182.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="300.000000pt" height="198.000000pt" viewBox="0 0 300.000000 198.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.10, written by Peter Selinger 2001-2011
</metadata>
<g transform="translate(0.000000,198.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M919 1902 c-55 -54 -80 -88 -88 -116 -6 -22 -8 -42 -6 -45 3 -2 21
14 41 37 37 44 143 112 173 112 10 0 23 -10 29 -22 16 -32 55 -179 49 -185 -3
-2 -24 8 -46 23 -39 25 -43 26 -85 14 -41 -11 -93 -46 -83 -56 2 -2 25 5 51
17 61 26 90 23 94 -13 4 -31 4 -31 -87 -191 -59 -106 -68 -117 -89 -112 -33 8
-44 -2 -30 -28 6 -12 19 -51 28 -87 14 -53 22 -67 43 -74 39 -14 56 -1 88 71
16 38 54 118 84 178 29 61 56 120 60 133 3 12 10 22 14 22 4 0 29 -42 56 -92
27 -51 61 -112 77 -136 104 -153 300 -257 387 -206 37 22 38 33 7 67 -28 31
-67 51 -125 67 -51 13 -139 67 -190 114 -18 17 -60 66 -92 109 -66 88 -92 158
-87 234 3 43 5 48 28 48 38 0 30 46 -17 100 -31 35 -52 47 -118 69 -44 14 -82
26 -85 26 -3 0 -39 -35 -81 -78z"/>
<path d="M2575 1910 c-3 -6 5 -14 18 -19 32 -12 40 -26 33 -54 -7 -30 -59 -59
-98 -55 -42 4 -46 -15 -9 -48 17 -15 31 -30 31 -34 0 -3 -25 -17 -56 -29 -54
-23 -92 -60 -80 -80 5 -7 15 -6 31 1 42 19 70 -1 72 -53 1 -24 -3 -50 -10 -58
-10 -12 -7 -13 21 -9 59 9 28 -16 -69 -54 -60 -24 -111 -37 -141 -38 -39 0
-46 -3 -41 -16 3 -9 19 -22 36 -29 27 -11 38 -9 119 26 93 41 118 47 118 27 0
-26 -52 -97 -87 -118 -34 -20 -36 -23 -18 -30 39 -14 113 22 132 63 30 64 43
99 43 111 0 24 135 37 156 16 7 -7 51 -10 110 -8 92 3 99 4 102 24 3 18 -8 26
-60 48 l-64 27 -97 -15 c-53 -9 -99 -16 -103 -16 -3 0 -4 10 -2 23 2 17 15 26
53 38 72 24 70 38 -9 63 -18 6 -17 9 9 36 18 19 26 36 22 49 -6 24 16 46 61
62 18 6 32 14 32 19 0 10 -57 82 -77 98 -9 6 -33 12 -52 12 -20 1 -47 7 -61
15 -30 17 -56 19 -65 5z m139 -98 c-31 -75 -50 -71 -28 6 13 43 18 51 30 41
11 -9 11 -17 -2 -47z m-94 -157 c0 -7 -12 -23 -26 -34 -24 -19 -27 -19 -60 -4
-19 9 -34 19 -34 23 0 3 19 15 43 27 44 21 77 17 77 -12z"/>
<path d="M579 1723 c-1 -4 0 -20 1 -35 1 -20 -3 -28 -15 -28 -10 0 -26 -11
-37 -25 -11 -14 -27 -25 -36 -25 -10 0 -22 -10 -27 -22 -7 -16 -18 -22 -33
-21 -25 3 -30 -14 -7 -23 22 -9 19 -71 -7 -107 -12 -18 -15 -26 -8 -19 19 16
30 15 30 -4 0 -15 -37 -53 -53 -54 -16 0 -47 93 -47 144 0 43 5 58 26 81 14
15 24 28 22 29 -2 0 -23 11 -48 24 -25 12 -48 22 -52 22 -12 0 -9 -34 6 -71 7
-19 19 -67 26 -108 7 -41 19 -89 26 -107 20 -47 18 -52 -21 -71 -28 -14 -35
-22 -35 -46 l0 -30 61 7 c47 4 82 0 159 -19 115 -29 183 -31 242 -8 59 22 71
32 57 49 -8 10 -31 11 -93 7 l-83 -6 -6 39 c-4 22 -7 67 -7 100 l0 61 43 16
c23 8 55 18 70 21 16 4 27 12 25 18 -7 19 -70 37 -110 31 -31 -4 -38 -2 -38
11 0 9 9 21 20 28 25 16 26 44 0 58 -10 6 -22 26 -26 45 -6 32 -23 57 -25 38z
m1 -211 c0 -34 -4 -40 -35 -55 -30 -14 -35 -15 -35 -2 0 20 -10 19 -26 -2 -12
-17 -13 -16 -14 4 0 12 8 24 18 27 10 4 31 20 47 36 16 17 32 30 37 30 4 0 8
-17 8 -38z m0 -124 c0 -52 -13 -65 -44 -44 -37 23 -32 12 14 -38 23 -24 37
-47 33 -49 -5 -3 -40 -2 -78 3 -39 5 -78 9 -87 10 -12 0 -18 8 -18 24 0 31 46
69 72 60 14 -4 21 0 25 15 5 21 46 50 71 51 7 0 12 -13 12 -32z"/>
<path d="M487 1643 c-4 -3 -7 -11 -7 -17 0 -6 5 -5 12 2 6 6 9 14 7 17 -3 3
-9 2 -12 -2z"/>
<path d="M1928 1644 c-26 -8 -22 -24 5 -24 74 0 143 -44 131 -83 -14 -43 -118
-190 -189 -267 -40 -42 -85 -96 -100 -119 l-28 -41 19 -38 c26 -51 64 -92 86
-92 9 0 31 19 48 43 17 23 54 73 81 111 108 147 189 299 189 352 0 48 -48 107
-110 133 -56 24 -104 33 -132 25z"/>
<path d="M2660 1395 c0 -2 34 -27 75 -55 41 -28 75 -56 75 -63 0 -7 18 -30 39
-51 48 -46 75 -46 79 3 4 39 -26 76 -93 116 -47 27 -175 64 -175 50z"/>
<path d="M2130 1227 c0 -8 12 -22 26 -32 14 -9 75 -64 135 -121 l110 -105 47
3 c42 3 47 6 50 29 3 22 -7 35 -55 75 -32 26 -83 58 -113 71 -30 13 -84 38
-119 56 -85 42 -81 41 -81 24z"/>
<path d="M522 1073 c-47 -50 -85 -100 -152 -197 -134 -194 -192 -276 -199
-281 -5 -3 -19 -26 -31 -51 -13 -25 -47 -78 -77 -117 -29 -39 -56 -81 -59 -93
-7 -28 22 -126 47 -158 11 -15 31 -26 43 -26 19 0 32 19 80 115 32 63 75 134
96 158 22 23 44 54 50 68 11 24 17 27 93 32 45 4 110 9 144 13 l62 7 6 -39 c4
-22 9 -47 12 -57 3 -13 -2 -20 -16 -24 -25 -6 -25 -5 -7 -63 36 -115 96 -178
112 -116 18 73 -35 570 -75 703 -5 18 -7 57 -4 86 12 114 -40 130 -125 40z
m72 -160 c3 -21 8 -69 11 -108 3 -38 8 -91 11 -116 l5 -46 -93 -5 c-51 -3
-101 -9 -112 -13 -16 -7 -17 -5 -11 16 4 13 23 49 43 79 19 30 40 68 46 85 14
38 78 145 87 145 4 0 10 -17 13 -37z"/>
<path d="M2788 994 c-4 -3 -1 -13 7 -21 8 -8 15 -23 15 -32 0 -24 19 -39 58
-47 28 -6 32 -4 32 15 0 41 -89 109 -112 85z"/>
<path d="M1245 974 c-33 -8 -84 -13 -113 -11 -40 3 -55 0 -59 -10 -10 -26 32
-55 84 -60 l49 -5 -12 -47 c-18 -68 -112 -318 -144 -382 -40 -78 -46 -82 -137
-78 -77 4 -77 4 -87 -24 -7 -19 -7 -33 1 -45 9 -15 34 -17 242 -16 205 1 234
4 251 19 27 24 12 48 -42 68 -54 20 -83 23 -131 13 -21 -5 -40 -6 -43 -3 -7 7
29 89 56 126 11 16 20 38 20 49 0 22 35 105 82 197 18 35 31 76 32 100 l1 40
75 3 c104 4 116 27 33 63 -51 22 -74 22 -158 3z"/>
<path d="M2600 984 c0 -8 36 -133 66 -226 39 -124 38 -126 -60 -181 -47 -26
-89 -47 -94 -47 -6 0 -24 -16 -40 -36 -24 -29 -30 -32 -36 -19 -7 20 -6 22 56
103 61 81 88 133 88 172 0 45 -38 96 -91 121 -56 26 -81 18 -49 -16 21 -22 21
-24 5 -56 -23 -44 -74 -108 -154 -192 l-65 -67 17 -35 c9 -20 17 -42 17 -49 0
-35 63 -59 88 -34 18 18 21 7 11 -42 -8 -40 -6 -51 18 -100 16 -30 36 -56 46
-58 15 -3 17 7 17 106 0 128 8 143 81 161 25 6 77 24 115 40 49 21 71 26 75
18 3 -7 16 -50 28 -97 12 -47 33 -120 47 -163 13 -43 24 -86 24 -96 0 -30 81
-168 107 -182 31 -17 54 -4 65 37 11 40 2 423 -10 454 -10 23 -11 23 -11 4 -1
-26 -47 -167 -69 -209 l-15 -30 -22 65 c-11 36 -26 74 -32 85 -15 26 -53 144
-53 165 0 9 12 23 28 31 15 8 42 24 61 37 19 12 49 22 67 22 18 0 36 5 40 12
13 20 -34 46 -108 58 -40 7 -91 20 -113 29 -37 17 -42 23 -64 92 -31 96 -48
129 -66 129 -8 0 -15 -3 -15 -6z m-176 -266 c-35 -50 -155 -180 -161 -174 -3
2 37 54 89 114 94 112 125 137 72 60z m346 22 c21 -11 41 -24 45 -29 5 -9 -43
-41 -60 -41 -7 0 -35 64 -35 80 0 14 5 13 50 -10z"/>
<path d="M1946 878 c-3 -7 -4 -20 -5 -28 0 -8 -3 -56 -7 -106 l-7 -92 -79 -31
c-44 -17 -86 -31 -94 -31 -19 0 -18 -16 2 -24 29 -11 79 -6 123 14 24 11 45
20 47 20 2 0 4 -30 4 -66 l0 -66 -28 5 c-16 3 -32 7 -38 8 -49 9 -104 -21
-104 -58 0 -15 2 -16 10 -3 12 19 104 20 138 2 18 -11 22 -21 22 -61 0 -27 -3
-63 -6 -80 -7 -32 -8 -32 -48 -25 -45 7 -77 21 -88 38 -6 8 -8 8 -8 0 0 -18
163 -174 182 -174 32 0 38 45 38 285 l0 233 46 16 c26 9 50 13 55 11 4 -3 11
-1 14 5 9 15 -13 40 -35 40 -32 0 -80 49 -80 81 0 59 -42 126 -54 87z"/>
<path d="M1531 674 c-11 -14 -26 -46 -34 -72 -40 -130 -39 -125 -22 -167 34
-81 68 -95 115 -45 l28 30 16 -25 c21 -32 50 -32 64 -1 31 68 21 237 -16 279
-18 20 -53 22 -71 5 -10 -11 -14 -10 -22 5 -14 24 -34 21 -58 -9z m119 -64 c0
-31 -4 -40 -17 -40 -10 0 -30 -7 -45 -14 -36 -19 -37 -2 -3 44 46 62 65 65 65
10z m-11 -103 c-23 -45 -30 -49 -50 -31 -26 24 -24 33 13 49 40 16 52 11 37
-18z m-61 -68 c-13 -8 -28 7 -28 30 0 11 5 10 20 -4 15 -14 17 -20 8 -26z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 989.55 925.64">
<defs>
<style>
.cls-1 {
font-family: Krungthep, Krungthep;
font-size: 92.87px;
}
.cls-2 {
fill: #020202;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<path class="cls-2" d="M412.28,0c21.28,5.72,40.43,15.59,58.14,28.66,7.12,5.25,12.47,11.82,17.59,18.76,1.92,2.6,4.75,5.22,3.21,8.74-1.62,3.73-5.57,2.84-8.73,2.95-16.39.58-32.74-.2-49.19,2.09-42.55,5.92-78.84,24.31-111.03,52.05-48.48,41.78-82.1,94.79-111.85,150.21-30.11,56.1-56.75,113.89-72.13,176.2-11.14,45.15-19.93,90.7-19.44,137.28.31,29.14,4.18,58.21,20.59,83.77,1.77,2.75,3.54,5.55,5.67,8.02,10.44,12.13,10.23,13.09-5.05,19.39-53.28,21.97-109.47-4.38-130.35-60.79-4.59-12.4-5.9-25.5-9.73-38.02v-27.12c2.91-18.86,3.06-37.98,4.31-56.94,2.44-36.98,12.09-72.25,22.29-107.44,19.91-68.68,49.59-132.98,88.75-192.85,18.68-28.57,39.12-55.93,62.76-80.53,35.21-36.64,68.51-75.39,113.61-101.27,23.59-13.53,48.25-21.17,75.24-21.91,2.01-.06,4.13.36,5.82-1.23h39.5Z"/>
<path class="cls-2" d="M792.19,131.3c-16.86,4.02-33.77,6.89-49.92,12.11-60.19,19.46-111.99,51.88-154.93,98.96-55.08,60.4-107.14,123.35-158.47,186.84-48.14,59.56-99.58,115.86-154.96,168.6-22.57,21.49-45.48,42.8-71.8,59.89-22.13,14.36-41.19,9.39-52.67-14.17-11.63-23.86-13.51-49.64-13.52-75.67,0-4.89,2.35-8.34,6.05-11.5,47.42-40.54,94.77-81.15,142.07-121.83,43.56-37.47,87.81-74.19,130.38-112.76,75.92-68.77,157.11-129.65,250.52-172.89,36.83-17.05,76.31-22.18,116.48-22.94,3.81-.07,8.38-.48,10.77,5.37Z"/>
<path class="cls-2" d="M795.46,490.45c-20.17-4.55-38.34-12.09-54.96-22.98-44.94-29.47-80.24-68.78-112.48-111.11-8.45-11.1-16-22.91-24.58-33.91-5.85-7.5-4.36-12.69,2.13-18.88,15.17-14.45,30.01-29.27,44.46-44.45,6.64-6.98,11.38-6.99,18.18-.06,24.18,24.64,49.97,47.41,78.35,67.31,34.67,24.31,72.81,39.36,114.51,45.94,8.93,1.41,17.75,3.48,26.67,4.93,7.45,1.21,10.05,4.16,6.34,11.67-20.49,41.45-48.2,76.2-90.04,98.03-2.86,1.49-5.98,2.47-8.57,3.52Z"/>
<path class="cls-2" d="M874.18,155.7c17.01,14.01,30.76,29.06,43.52,44.96,31.34,39.05,54.92,81.89,61.08,132.44,5.37,44.12-2.85,86.74-30.05,121.33-25.28,32.16-62,47.85-104.39,45.61-4.25-.22-10.55,0-11.8-4.7-1.41-5.29,5.19-6.47,8.54-9.01,46.66-35.45,80.06-80.91,87.6-139.08,8.67-66.91-6.32-129.76-51.65-182.74-1.54-1.8-3.86-3.24-2.84-8.8Z"/>
<path class="cls-2" d="M393.2,88.88c29.32-14.72,57.46-19.83,87.21-16.33,3.67.43,7.31,1.12,10.99,1.51,19.26,2.03,35.26,8.92,46,26.49,6.81,11.15,15.5,21.23,23.97,31.26,5.93,7.03,3.97,11.44-2.86,15.78-13.88,8.82-27.81,17.57-41.36,26.87-6.79,4.66-10.95,2.94-15.26-3.19-19.76-28.08-45.74-49.27-75.28-66.2-9.89-5.67-20.53-10.01-33.4-16.19Z"/>
</g>
<g>
<path class="cls-2" d="M642.58,733.79c.17,2.58.53,4.27.34,5.88-1.29,10.63-1.84,11.14-12.88,11.16-28.21.04-56.41,0-84.62.06-3.05,0-6.28.09-9.09,1.09-1.33.48-2.39,3.16-2.52,4.92-.07.87,2.12,2.52,3.49,2.77,2.67.5,5.48.32,8.22.32,28.89.02,57.79.01,86.68.02,2.41,0,4.82.11,7.22.04,5.04-.15,6.34,2.43,6.2,7.12-.43,15.28-2,16.99-17.13,16.99-88.4,0-176.81-.05-265.21.05-16.7.02-14.53-1.71-12.47-14.02,1.68-10.04,1.54-10.14,12.36-10.16,30.27-.04,60.54,0,90.81-.04,3.08,0,6.23.02,9.22-.61,1.25-.27,3.14-2.35,2.99-3.35-.24-1.64-1.63-4.01-3.02-4.41-2.89-.83-6.1-.73-9.18-.73-25.45-.04-50.91-.03-76.36-.03-2.41,0-4.82.05-7.22-.02-7.95-.25-8.85-.92-8.95-6.56-.15-8.44,1.77-10.89,9.63-10.93,28.21-.12,56.41-.05,84.62-.07,3.1,0,6.19.02,9.28-.13,2.92-.15,5.62-.87,5.47-4.61-.15-3.75-3.08-4.1-5.87-4.28-2.74-.18-5.5-.12-8.25-.13-23.05,0-46.09,0-69.14,0-2.41,0-4.82.07-7.22-.03-7.8-.33-8.69-1.35-7.79-9.4.46-4.08,1.25-8.12,1.82-12.19,1.85-13.22,3.54-26.47,5.56-39.67,1.48-9.68,1.67-9.77,11.82-9.8,24.76-.07,49.53-.02,74.29-.02,55.72,0,111.45,0,167.17,0,2.75,0,5.51.13,8.25.02,4.93-.19,6.92,2.06,6.23,6.91-2.74,19.32-5.51,38.64-8.1,57.98-.56,4.21-2.71,6.05-6.76,6.09-4.81.04-9.63.1-14.45.1-22.7.01-45.41,0-68.11.02-2.75,0-5.66-.38-8.19.4-1.9.58-3.3,2.75-4.93,4.2,1.56,1.45,2.89,3.61,4.72,4.16,2.54.76,5.45.37,8.19.37,27.17.02,54.35,0,81.52.03,3.68,0,7.35.31,11.35.5ZM455.08,670.86c-6.47,0-12.98-.37-19.4.21-2.05.19-5.2,2.68-5.5,4.49-.61,3.7,3.08,3.81,5.74,3.84,10.56.13,21.13.1,31.69.08,2.04,0,4.39.35,6.03-.52,1.78-.94,2.89-3.12,4.3-4.75-1.49-1.12-2.92-3.11-4.47-3.2-6.11-.37-12.25-.15-18.39-.16ZM568.87,679.4v.1c5.11,0,10.22.09,15.33-.04,2.01-.05,4.37-.05,5.89-1.08,1.37-.93,2.31-3.18,2.45-4.93.06-.77-2.3-2.49-3.58-2.51-12.94-.17-25.89-.23-38.83.12-1.75.05-4.55,2.23-4.91,3.86-.82,3.65,2.44,4.37,5.25,4.44,6.13.15,12.26.05,18.4.05ZM448.88,706.09s0,.06,0,.09c5.83,0,11.66.09,17.49-.04,3.45-.08,7.64-.22,7.54-4.87-.09-4.28-4.23-3.76-7.17-3.79-10.63-.12-21.26-.08-31.89-.03-1.7,0-3.72-.16-5.01.66-1.52.96-3.2,2.87-3.28,4.45-.06,1.11,2.39,3.27,3.82,3.36,6.15.38,12.33.16,18.5.16ZM564.82,706.13c0-.07,0-.13,0-.2,6.52,0,13.07.31,19.56-.21,1.66-.13,3.1-2.91,4.64-4.47-1.66-1.25-3.27-3.5-4.99-3.58-8.23-.4-16.49-.27-24.74-.23-4.46.02-8.99-.2-13.35.53-1.79.3-3.19,2.85-4.77,4.37,1.7,1.27,3.32,3.49,5.1,3.62,6.15.45,12.36.17,18.54.17Z"/>
<path class="cls-2" d="M868.37,736.34c.93-2.22,1.01-3.06,1.46-3.4,12.08-9.08,14.69-22.06,16.41-36.11,3.71-30.24,8.49-60.36,12.83-90.52.2-1.36.44-2.7.64-4.06.49-3.35.06-6.38-3.88-7.18-3.4-.69-7.06-1.04-9.18,2.61-1.19,2.05-1.99,4.37-2.64,6.66-11.58,40.52-23.16,81.03-34.64,121.58-2.94,10.37-2.8,10.46-14.05,10.52-10.91.06-21.82.01-34.19.01,3.56-12.77,6.6-23.89,9.76-34.99,9.17-32.29,18.43-64.56,27.56-96.87,2.22-7.86.78-9.72-7.81-9.94-9.28-.24-18.57-.03-27.86-.1-3.38-.02-6.75-.28-10.77-.47-1.09-9.14.84-17.37,2.71-25.55,1.05-4.59,5.14-3.82,8.47-3.83,16.51-.07,33.02-.04,49.54-.04,2.41,0,4.82.03,7.22-.01,10.21-.18,14.44-7.01,9.93-16.18-1.15-2.34-2.73-4.48-4.59-7.48,2.77-.7,4.63-1.57,6.5-1.58,16.17-.12,32.34-.22,48.5-.02,7.51.09,8.2,1.23,7.24,8.67q-2.15,16.59,14.3,16.61c12.04,0,24.08-.03,36.12.03,3.36.02,6.73.31,11.61.56-1.25,8.79-2.38,16.77-3.51,24.75-.58,4.08-3.6,4.52-6.87,4.53-7.57.03-15.14-.09-22.7.1-7.82.19-8.56.8-9.73,8.79-4.84,33.21-9.54,66.43-14.35,99.65-1.83,12.66-6.05,24.01-18.32,30.38-2.67,1.39-5.76,2.69-8.69,2.77-11.99.31-23.98.13-37.02.13Z"/>
<path class="cls-2" d="M971.24,753.75c-1.38,9.62-2.64,17.36-3.55,25.14-.54,4.61-2.77,6.73-7.38,6.6-2.75-.08-5.5.03-8.25.03-48.49,0-96.98-.46-145.45.24-17.85.26-34.82-2.54-51.64-7.68-1.97-.6-3.94-1.22-5.92-1.79-5.09-1.46-9.24-.84-12.55,4.08-1.51,2.24-4.61,4.76-7.05,4.83-15.41.44-30.83.22-46.48.22-1.46-5.71,1.81-9.4,4.11-12.69,8.56-12.25,11.46-26.01,13.27-40.48,3.4-27.17,7.57-54.24,11.39-81.36.33-2.37.54-4.76.72-7.15.35-4.67-.75-8.27-6.45-8.21-5.25.06-6.35-3.05-5.79-7.44.31-2.37.68-4.74,1.06-7.11q2.55-15.9,19.17-15.91c13.76,0,27.51-.1,41.26.05,9.64.1,10.77,1.29,9.45,11-3.59,26.45-7.42,52.88-11.17,79.31-1.54,10.85-3.22,21.67-4.63,32.54-1.32,10.15-.37,11.46,9.82,14.39,10.24,2.95,20.42,6.14,30.79,8.52,6.96,1.59,14.2,2.7,21.32,2.74,46.77.24,93.53.12,140.3.12,4.06,0,8.13,0,13.66,0Z"/>
<path class="cls-2" d="M386.62,616.93c1.7-14.64,3.11-27.81,4.81-40.94,1.05-8.13,2.62-16.19,3.84-24.3.76-5.11,3.03-8.23,8.76-7.58,1.36.15,2.75,0,4.12,0,81.07,0,162.13,0,243.2.02,3.67,0,7.33.35,12.14.6-1.25,9.12-2.35,17.17-3.45,25.23-1.8,13.21-3.71,26.41-5.33,39.64-.67,5.47-3.29,7.89-8.82,7.35-2.04-.2-4.12-.02-6.18-.02-80.04,0-160.07,0-240.11,0-4.01,0-8.02,0-12.97,0ZM523.06,590.31c-22.98,0-45.96,0-68.94.01-2.74,0-5.59-.25-8.18.44-1.6.42-3.75,2.3-3.88,3.69-.11,1.29,1.88,3.34,3.41,4.03,1.76.79,4.03.56,6.08.56,47.68.02,95.36.02,143.03,0,2.05,0,4.33.26,6.08-.53,1.52-.69,3.45-2.76,3.36-4.09-.1-1.36-2.26-3.25-3.84-3.67-2.59-.68-5.44-.42-8.18-.42-22.98-.02-45.96-.01-68.94-.01ZM526.45,570.84c24.39,0,48.78.04,73.17-.05,3.23-.01,8.18.98,8.19-3.73,0-4.9-5.06-3.51-8.17-3.54-16.13-.14-32.27-.03-48.4-.03-31.26,0-62.52-.04-93.78-.04-2.74,0-5.56-.1-8.19.51-1.31.3-3.05,2.05-3.13,3.25-.07,1.02,1.79,2.87,3.07,3.15,2.64.58,5.45.46,8.19.46,23.02.03,46.03.01,69.05,0Z"/>
<path class="cls-2" d="M664.1,626.08c.35,2.15.7,3.13.65,4.09-.72,13.47-.79,13.54-14.36,13.56-68.72.09-137.44.18-206.16.22-21.6.01-43.19-.1-64.79-.24-9.57-.07-12.19-3.9-8.35-12.73.89-2.03,3.57-3.94,5.81-4.58,2.87-.81,6.13-.31,9.22-.32,59.78,0,119.56,0,179.34,0,29.21,0,58.41,0,87.62,0,3.68,0,7.37,0,11.02,0Z"/>
<path class="cls-2" d="M951.96,661.67c0-10.27-.11-20.54.03-30.8.12-8.74.54-9.1,9.52-9.28,5.49-.11,10.99.02,16.49.09,3.73.05,5.71,1.93,5.76,5.7.03,2.74.15,5.48.06,8.21-.56,18.79-1.12,37.58-1.77,56.37-.32,9.24-.43,9.31-9.23,9.51-4.81.11-9.62-.12-14.43,0-4.9.12-6.67-2.33-6.63-6.94.1-10.95.03-21.9.03-32.86.05,0,.11,0,.16,0Z"/>
<path class="cls-2" d="M769.69,700.98c2.74-10.45,5-19.3,7.4-28.12,4.14-15.17,8.28-30.34,12.59-45.46,2.46-8.64,3.22-9.12,12.08-9.24,6.07-.09,12.13-.02,19.5-.02-1.06,4.9-1.61,8.18-2.48,11.38-5.76,21.1-11.57,42.2-17.42,63.28-2.35,8.46-2.41,8.57-11.24,8.65-6.35.06-12.7-.28-20.42-.47Z"/>
<path class="cls-2" d="M778.14,586.16c-8.58,0-15.33,0-22.09,0-7.56,0-15.12-.07-22.68-.02-3.38.03-6.1-.8-7.29-4.34-3.77-11.29-7.54-22.59-11.52-34.5,2.84-.76,4.7-1.68,6.58-1.7,12.03-.14,24.06-.1,36.08-.07,3.48,0,7.16,0,8.57,4.09,3.98,11.56,7.84,23.16,12.35,36.53Z"/>
</g>
<text class="cls-1" transform="translate(360.91 887.84)"><tspan x="0" y="0">QUANT SPEED</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

41
frontend/src/App.css Normal file
View File

@@ -0,0 +1,41 @@
#root {
width: 100%;
margin: 0;
padding: 0;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

30
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Home from './pages/Home';
import ProductDetail from './pages/ProductDetail';
import Payment from './pages/Payment';
import AIServices from './pages/AIServices';
import ServiceDetail from './pages/ServiceDetail';
import ARExperience from './pages/ARExperience';
import 'antd/dist/reset.css';
import './App.css';
function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/services" element={<AIServices />} />
<Route path="/services/:id" element={<ServiceDetail />} />
<Route path="/ar" element={<ARExperience />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/payment/:orderId" element={<Payment />} />
</Routes>
</Layout>
</BrowserRouter>
)
}
export default App

22
frontend/src/api.js Normal file
View File

@@ -0,0 +1,22 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
}
});
export const getConfigs = () => api.get('/configs/');
export const createOrder = (data) => api.post('/orders/', data);
export const getOrder = (id) => api.get(`/orders/${id}/`);
export const initiatePayment = (orderId) => api.post(`/orders/${orderId}/initiate_payment/`);
export const confirmPayment = (orderId) => api.post(`/orders/${orderId}/confirm_payment/`);
export const getServices = () => api.get('/services/');
export const getServiceDetail = (id) => api.get(`/services/${id}/`);
export const createServiceOrder = (data) => api.post('/service-orders/', data);
export const getARServices = () => api.get('/ar/');
export default api;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button } from 'antd';
import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined } from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import ParticleBackground from './ParticleBackground';
import { motion, AnimatePresence } from 'framer-motion';
const { Header, Content, Footer } = AntLayout;
const Layout = ({ children }) => {
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const items = [
{
key: '/',
icon: <RobotOutlined />,
label: 'AI 硬件',
},
{
key: '/services',
icon: <AppstoreOutlined />,
label: 'AI 服务',
},
{
key: '/ar',
icon: <EyeOutlined />,
label: 'AR 体验',
},
{
key: 'more',
label: '...',
},
];
const handleMenuClick = (key) => {
if (key === 'more') return;
navigate(key);
setMobileMenuOpen(false);
};
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#00b96b',
colorBgContainer: 'transparent',
colorBgLayout: 'transparent',
fontFamily: "'Orbitron', sans-serif",
},
}}
>
<ParticleBackground />
<AntLayout style={{ minHeight: '100vh', background: 'transparent' }}>
<Header
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 1000,
width: '100%',
padding: 0,
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
height: '72px',
lineHeight: '72px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.5)'
}}
>
<div style={{
width: '100%',
padding: '0 40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '100%'
}}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
style={{
color: '#fff',
fontSize: '20px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
cursor: 'pointer'
}}
onClick={() => navigate('/')}
>
<img src="/liangji_logo.svg" alt="Quant Speed Logo" style={{ height: '40px', filter: 'invert(1) brightness(2)' }} />
</motion.div>
{/* Desktop Menu */}
<div className="desktop-menu" style={{ display: 'none', flex: 1 }}>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[location.pathname]}
items={items}
onClick={(e) => handleMenuClick(e.key)}
style={{
background: 'transparent',
borderBottom: 'none',
display: 'flex',
justifyContent: 'flex-end',
minWidth: '400px'
}}
/>
</div>
<style>{`
@media (min-width: 768px) {
.desktop-menu { display: block !important; }
.mobile-menu-btn { display: none !important; }
}
`}</style>
{/* Mobile Menu Button */}
<Button
className="mobile-menu-btn"
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setMobileMenuOpen(true)}
/>
</div>
</Header>
{/* Mobile Drawer Menu */}
<Drawer
title={<span style={{ color: '#00b96b' }}>导航菜单</span>}
placement="right"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }}
>
<Menu
theme="dark"
mode="vertical"
selectedKeys={[location.pathname]}
items={items}
onClick={(e) => handleMenuClick(e.key)}
style={{ background: 'transparent', borderRight: 'none' }}
/>
</Drawer>
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
minHeight: 'calc(100vh - 128px)'
}}>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: -20, filter: 'blur(10px)' }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
</Content>
<Footer style={{ textAlign: 'center', background: 'rgba(0,0,0,0.5)', color: '#666', backdropFilter: 'blur(5px)' }}>
Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech
</Footer>
</AntLayout>
</ConfigProvider>
);
};
export default Layout;

View File

@@ -0,0 +1,70 @@
import React, { Suspense } from 'react';
import { Canvas, useLoader } from '@react-three/fiber';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei';
import { Spin } from 'antd';
const Model = ({ objPath, mtlPath, scale = 1 }) => {
const materials = useLoader(MTLLoader, mtlPath);
const obj = useLoader(OBJLoader, objPath, (loader) => {
materials.preload();
loader.setMaterials(materials);
});
const clone = obj.clone();
return <primitive object={clone} scale={scale} />;
};
const LoadingOverlay = () => {
const { progress, active } = useProgress();
if (!active) return null;
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
zIndex: 10,
pointerEvents: 'none'
}}>
<div style={{ textAlign: 'center' }}>
<Spin size="large" />
<div style={{ color: '#00b96b', marginTop: 10, fontWeight: 'bold' }}>
{progress.toFixed(0)}% Loading
</div>
</div>
</div>
);
};
const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => {
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<LoadingOverlay />
<Canvas shadows dpr={[1, 2]} camera={{ fov: 45, position: [0, 0, 5] }} style={{ height: '100%', width: '100%' }}>
<ambientLight intensity={0.7} />
<pointLight position={[10, 10, 10]} intensity={1} />
<spotLight position={[-10, 10, 10]} angle={0.15} penumbra={1} intensity={1} />
<Suspense fallback={null}>
<Stage environment="city" intensity={0.6} adjustCamera={true}>
<Model objPath={objPath} mtlPath={mtlPath} scale={scale} />
</Stage>
<Environment preset="city" />
<ContactShadows position={[0, -0.8, 0]} opacity={0.4} scale={10} blur={2} far={0.8} />
</Suspense>
<OrbitControls autoRotate={autoRotate} makeDefault />
</Canvas>
</div>
);
};
export default ModelViewer;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useRef } from 'react';
const ParticleBackground = () => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let animationFrameId;
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const particles = [];
const particleCount = 100;
class Particle {
constructor() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.vx = (Math.random() - 0.5) * 0.5;
this.vy = (Math.random() - 0.5) * 0.5;
this.size = Math.random() * 2;
this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '; // Green or Blue
}
update() {
this.x += this.vx;
this.y += this.vy;
if (this.x < 0 || this.x > canvas.width) this.vx *= -1;
if (this.y < 0 || this.y > canvas.height) this.vy *= -1;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = this.color + Math.random() * 0.5 + ')';
ctx.fill();
}
}
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw connecting lines
ctx.lineWidth = 0.5;
for (let i = 0; i < particleCount; i++) {
for (let j = i; j < particleCount; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 100) {
ctx.beginPath();
ctx.strokeStyle = `rgba(100, 255, 218, ${1 - distance / 100})`;
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
particles.forEach(p => {
p.update();
p.draw();
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
return () => {
window.removeEventListener('resize', resizeCanvas);
cancelAnimationFrame(animationFrameId);
};
}, []);
return <canvas ref={canvasRef} id="particle-canvas" />;
};
export default ParticleBackground;

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
body {
margin: 0;
padding: 0;
font-family: 'Orbitron', 'Roboto', sans-serif; /* 假设引入了科技感字体 */
background-color: #050505;
color: #fff;
overflow-x: hidden;
}
/* 全局滚动条美化 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #000;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #00b96b;
}
/* 霓虹光效工具类 */
.neon-text-green {
color: #00b96b;
text-shadow: 0 0 5px rgba(0, 185, 107, 0.5), 0 0 10px rgba(0, 185, 107, 0.3);
}
.neon-text-blue {
color: #00f0ff;
text-shadow: 0 0 5px rgba(0, 240, 255, 0.5), 0 0 10px rgba(0, 240, 255, 0.3);
}
.neon-border {
border: 1px solid rgba(0, 185, 107, 0.3);
box-shadow: 0 0 10px rgba(0, 185, 107, 0.1), inset 0 0 10px rgba(0, 185, 107, 0.1);
}
/* 玻璃拟态 */
.glass-panel {
background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
}
/* 粒子背景容器 */
#particle-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Typography, Button, Spin } from 'antd';
import { motion } from 'framer-motion';
import { RightOutlined } from '@ant-design/icons';
import { getServices } from '../api';
import { useNavigate } from 'react-router-dom';
const { Title, Paragraph } = Typography;
const AIServices = () => {
const [services, setServices] = useState([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
const fetchServices = async () => {
try {
const response = await getServices();
setServices(response.data);
} catch (error) {
console.error("Failed to fetch services:", error);
} finally {
setLoading(false);
}
};
fetchServices();
}, []);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="Loading services..." />
</div>
);
}
return (
<div style={{ padding: '20px 0' }}>
<div style={{ textAlign: 'center', marginBottom: 60 }}>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8 }}
>
<Title level={1} style={{ color: '#fff', fontSize: 'clamp(2rem, 4vw, 3rem)' }}>
AI 全栈<span style={{ color: '#00f0ff', textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>解决方案</span>
</Title>
</motion.div>
<Paragraph style={{ color: '#888', maxWidth: 700, margin: '0 auto', fontSize: 16 }}>
从数据处理到模型部署我们为您提供一站式 AI 基础设施服务
</Paragraph>
</div>
<Row gutter={[32, 32]} justify="center">
{services.map((item, index) => (
<Col xs={24} md={8} key={item.id}>
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.2, duration: 0.5 }}
whileHover={{ scale: 1.03 }}
onClick={() => navigate(`/services/${item.id}`)}
style={{ cursor: 'pointer' }}
>
<div
className="glass-panel"
style={{
padding: 30,
height: '100%',
position: 'relative',
overflow: 'hidden',
border: `1px solid ${item.color}33`,
boxShadow: `0 0 20px ${item.color}11`
}}
>
{/* HUD 装饰线 */}
<div style={{ position: 'absolute', top: 0, left: 0, width: 20, height: 2, background: item.color }} />
<div style={{ position: 'absolute', top: 0, left: 0, width: 2, height: 20, background: item.color }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 20, height: 2, background: item.color }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 2, height: 20, background: item.color }} />
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center' }}>
<div style={{
width: 60, height: 60,
borderRadius: '50%',
background: `${item.color}22`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 15,
overflow: 'hidden'
}}>
{item.display_icon ? (
<img src={item.display_icon} alt={item.title} style={{ width: '60%', height: '60%', objectFit: 'contain' }} />
) : (
<div style={{ width: 30, height: 30, background: item.color, borderRadius: '50%' }} />
)}
</div>
<h3 style={{ margin: 0, fontSize: 22, color: '#fff' }}>{item.title}</h3>
</div>
<p style={{ color: '#ccc', lineHeight: 1.6, minHeight: 60 }}>{item.description}</p>
<div style={{ marginTop: 20 }}>
{item.features_list && item.features_list.map((feat, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', marginBottom: 8, color: item.color
}}>
<div style={{ width: 6, height: 6, background: item.color, marginRight: 10, borderRadius: '50%' }} />
{feat}
</div>
))}
</div>
<Button
type="link"
style={{ padding: 0, marginTop: 20, color: '#fff' }}
icon={<RightOutlined />}
onClick={(e) => {
e.stopPropagation();
navigate(`/services/${item.id}`);
}}
>
了解更多
</Button>
</div>
</motion.div>
</Col>
))}
</Row>
{/* 动态流程图模拟 */}
<motion.div
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
style={{ marginTop: 80, padding: 40, background: 'rgba(0,0,0,0.3)', borderRadius: 20, border: '1px dashed #333', textAlign: 'center' }}
>
<Title level={3} style={{ color: '#fff', marginBottom: 40 }}>服务流程</Title>
<Row justify="space-around" align="middle" gutter={[20, 20]}>
{['需求分析', '数据准备', '模型训练', '测试验证', '私有化部署'].map((step, i) => (
<Col key={i} xs={12} md={4}>
<div style={{
width: '100%', aspectRatio: '1',
border: '2px solid #333', borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#888', fontSize: 16, fontWeight: 'bold',
position: 'relative'
}}>
{step}
{/* 简单的连接线模拟 */}
{i < 4 && (
<div className="process-arrow" style={{
position: 'absolute', right: -20, top: '50%',
width: 20, height: 2, background: '#333',
display: 'none' // 移动端隐藏
}} />
)}
</div>
</Col>
))}
</Row>
<style>{`
@media (min-width: 768px) {
.process-arrow { display: block !important; }
}
`}</style>
</motion.div>
</div>
);
};
export default AIServices;

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Button, Typography, Spin, Row, Col, Empty } from 'antd';
import { ScanOutlined } from '@ant-design/icons';
import { getARServices } from '../api';
const { Title, Paragraph } = Typography;
const ARExperience = () => {
const [scanning, setScanning] = useState(true);
const [arServices, setArServices] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAR = async () => {
try {
const res = await getARServices();
setArServices(res.data);
} catch (error) {
console.error("Failed to fetch AR services:", error);
} finally {
setLoading(false);
}
}
fetchAR();
}, []);
if (loading) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
return (
<div style={{ padding: '40px 0', minHeight: '80vh', position: 'relative' }}>
<div style={{ textAlign: 'center', marginBottom: 60, position: 'relative', zIndex: 2 }}>
<Title level={1} style={{ color: '#fff', letterSpacing: 4 }}>
AR <span style={{ color: '#00f0ff' }}>UNIVERSE</span>
</Title>
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
探索全息增强现实体验请佩戴您的设备或使用移动端摄像头扫描空间
</Paragraph>
</div>
{arServices.length === 0 ? (
<div style={{ textAlign: 'center', marginTop: 100, zIndex: 2, position: 'relative' }}>
<Empty description={<span style={{ color: '#666' }}>暂无 AR 体验内容</span>} />
</div>
) : (
<Row gutter={[32, 32]} justify="center" style={{ padding: '0 20px', position: 'relative', zIndex: 2 }}>
{arServices.map((item, index) => (
<Col xs={24} md={12} lg={8} key={item.id}>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
<div style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(0,240,255,0.2)',
borderRadius: 12,
overflow: 'hidden',
height: '100%'
}}>
<div style={{ height: 200, background: '#000', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.display_cover_image ? (
<img src={item.display_cover_image} alt={item.title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ScanOutlined style={{ fontSize: 40, color: '#333' }} />
)}
</div>
<div style={{ padding: 20 }}>
<h3 style={{ color: '#fff', fontSize: 20 }}>{item.title}</h3>
<p style={{ color: '#888', marginBottom: 20, minHeight: 44 }}>{item.description}</p>
<Button type="primary" block ghost style={{ borderColor: '#00f0ff', color: '#00f0ff' }}>
启动体验
</Button>
</div>
</div>
</motion.div>
</Col>
))}
</Row>
)}
{/* 装饰性背景 */}
<div style={{
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%)
`,
zIndex: 0,
pointerEvents: 'none'
}} />
<div style={{
position: 'fixed',
bottom: 0,
width: '100%',
height: '300px',
background: `linear-gradient(to top, rgba(0,0,0,0.8), transparent)`,
zIndex: 1,
pointerEvents: 'none'
}} />
</div>
);
};
export default ARExperience;

View File

@@ -0,0 +1,78 @@
.tech-card {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid #303030 !important;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: none !important; /* 强制移除默认阴影 */
overflow: hidden; /* 确保子元素不会溢出产生黑边 */
outline: none;
}
.tech-card:hover {
border-color: #00b96b !important;
box-shadow: 0 0 20px rgba(0, 185, 107, 0.4) !important; /* 增强悬停发光 */
transform: translateY(-5px);
}
.tech-card .ant-card-body {
border-top: none !important;
box-shadow: none !important;
}
.tech-card-title {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.tech-price {
color: #00b96b;
font-size: 20px;
font-weight: bold;
}
.product-scroll-container {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
padding: 30px 20px; /* 增加左右内边距,为悬停缩放和投影留出空间 */
margin: 0 -20px; /* 使用负外边距抵消内边距,使滚动条能延伸到版心边缘 */
width: calc(100% + 40px);
}
/* 自定义滚动条 */
.product-scroll-container::-webkit-scrollbar {
height: 6px;
}
.product-scroll-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
margin: 0 20px; /* 让滚动条轨道在版心内显示 */
}
.product-scroll-container::-webkit-scrollbar-thumb {
background: rgba(0, 185, 107, 0.2);
border-radius: 3px;
transition: all 0.3s;
}
.product-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 185, 107, 0.5);
}
/* 布局对齐 */
.product-scroll-container .ant-row {
margin-left: 0 !important;
margin-right: 0 !important;
padding: 0;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
}
.product-scroll-container .ant-col {
flex: 0 0 320px;
padding: 0 12px;
}

177
frontend/src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,177 @@
import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Tag, Button, Spin, Typography } from 'antd';
import { RocketOutlined, RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { getConfigs } from '../api';
import './Home.css';
const { Title, Paragraph } = Typography;
const Home = () => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [typedText, setTypedText] = useState('');
const [isTypingComplete, setIsTypingComplete] = useState(false);
const fullText = "未来已来 AI 核心驱动";
const navigate = useNavigate();
useEffect(() => {
fetchProducts();
let i = 0;
const typingInterval = setInterval(() => {
i++;
setTypedText(fullText.slice(0, i));
if (i >= fullText.length) {
clearInterval(typingInterval);
setIsTypingComplete(true);
}
}, 150);
return () => clearInterval(typingInterval);
}, []);
const fetchProducts = async () => {
try {
const response = await getConfigs();
setProducts(response.data);
} catch (error) {
console.error('Failed to fetch products:', error);
} finally {
setLoading(false);
}
};
const cardVariants = {
hidden: { opacity: 0, y: 50 },
visible: (i) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.5,
type: "spring",
stiffness: 100
}
}),
hover: {
scale: 1.05,
rotateX: 5,
rotateY: 5,
transition: { duration: 0.3 }
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
<Spin size="large" tip="加载硬件配置中..." />
</div>
);
}
return (
<div>
<div style={{ textAlign: 'center', marginBottom: 60 }}>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1 }}
style={{ marginBottom: 30 }}
>
<motion.img
src="/gXEu5E01.svg"
alt="Quant Speed Logo"
animate={{
filter: [
'invert(1) brightness(2) drop-shadow(0 0 10px rgba(0, 240, 255, 0.3))',
'invert(1) brightness(2) drop-shadow(0 0 20px rgba(0, 240, 255, 0.7))',
'invert(1) brightness(2) drop-shadow(0 0 10px rgba(0, 240, 255, 0.3))'
]
}}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
style={{ width: 180, height: 'auto' }}
/>
</motion.div>
<Title level={1} style={{ color: '#fff', fontSize: 'clamp(2rem, 5vw, 4rem)', marginBottom: 20, minHeight: '60px' }}>
<span className="neon-text-green">{typedText}</span>
{!isTypingComplete && <span className="cursor-blink">|</span>}
</Title>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2, duration: 1 }}
>
<Paragraph style={{ color: '#aaa', fontSize: '18px', maxWidth: 600, margin: '0 auto', lineHeight: '1.6' }}>
量迹 AI 硬件为您提供最强大的边缘计算能力搭载最新一代神经处理单元赋能您的每一个创意
</Paragraph>
</motion.div>
</div>
<div className="product-scroll-container">
<Row gutter={[24, 24]} wrap={false}>
{products.map((product, index) => (
<Col key={product.id} flex="0 0 320px">
<motion.div
custom={index}
initial="hidden"
animate="visible"
whileHover="hover"
variants={cardVariants}
style={{ perspective: 1000 }}
>
<Card
className="tech-card glass-panel"
bordered={false}
cover={
<div style={{
height: 200,
background: 'linear-gradient(135deg, rgba(31,31,31,0.8), rgba(42,42,42,0.8))',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: '#444',
borderBottom: '1px solid rgba(255,255,255,0.05)'
}}>
<motion.div
animate={{ y: [0, -10, 0] }}
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
>
<RocketOutlined style={{ fontSize: 60, color: '#00b96b' }} />
</motion.div>
</div>
}
onClick={() => navigate(`/product/${product.id}`)}
>
<div className="tech-card-title neon-text-blue">{product.name}</div>
<div style={{ marginBottom: 10, height: 40, overflow: 'hidden', color: '#bbb' }}>
{product.description}
</div>
<div style={{ marginBottom: 15 }}>
<Tag color="cyan" style={{ background: 'rgba(0,255,255,0.1)', border: '1px solid cyan' }}>{product.chip_type}</Tag>
{product.has_camera && <Tag color="blue" style={{ background: 'rgba(0,0,255,0.1)', border: '1px solid blue' }}>Camera</Tag>}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="tech-price neon-text-green">¥{product.price}</div>
<Button type="primary" shape="circle" icon={<RightOutlined />} style={{ background: '#00b96b', borderColor: '#00b96b' }} />
</div>
</Card>
</motion.div>
</Col>
))}
</Row>
</div>
<style>{`
.cursor-blink {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,52 @@
.payment-container {
max-width: 600px;
margin: 50px auto;
padding: 40px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid #303030;
border-radius: 12px;
text-align: center;
}
.payment-title {
color: #fff;
font-size: 28px;
margin-bottom: 30px;
}
.payment-amount {
font-size: 48px;
color: #00b96b;
font-weight: bold;
margin: 20px 0;
}
.payment-info {
text-align: left;
background: rgba(0,0,0,0.3);
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
color: #ccc;
}
.payment-method {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 30px;
}
.payment-method-item {
border: 1px solid #444;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
color: #fff;
}
.payment-method-item.active {
border-color: #00b96b;
background: rgba(0, 185, 107, 0.1);
}

View File

@@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, message, Result, Spin, QRCode } from 'antd';
import { WechatOutlined, AlipayCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { getOrder, initiatePayment, confirmPayment } from '../api';
import './Payment.css';
const Payment = () => {
const { orderId } = useParams();
const navigate = useNavigate();
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [paySuccess, setPaySuccess] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('wechat');
useEffect(() => {
fetchOrder();
}, [orderId]);
const fetchOrder = async () => {
try {
const response = await getOrder(orderId);
setOrder(response.data);
} catch (error) {
console.error('Failed to fetch order:', error);
// Fallback if getOrder API fails (404/405), we might show basic info or error
// Assuming for now it works or we handle it
message.error('无法获取订单信息,请重试');
} finally {
setLoading(false);
}
};
const handlePay = async () => {
if (paymentMethod === 'alipay') {
message.info('暂未开通支付宝支付,请使用微信支付');
return;
}
setPaying(true);
try {
// 1. 获取微信支付参数
const response = await initiatePayment(orderId);
const payData = response.data;
if (typeof WeixinJSBridge === 'undefined') {
message.warning('请在微信内置浏览器中打开以完成支付');
setPaying(false);
return;
}
// 2. 调用微信支付
const onBridgeReady = () => {
window.WeixinJSBridge.invoke(
'getBrandWCPayRequest', {
"appId": payData.appId, // 公众号名称,由商户传入
"timeStamp": payData.timeStamp, // 时间戳自1970年以来的秒数
"nonceStr": payData.nonceStr, // 随机串
"package": payData.package,
"signType": payData.signType, // 微信签名方式:
"paySign": payData.paySign // 微信签名
},
function(res) {
setPaying(false);
if (res.err_msg == "get_brand_wcpay_request:ok") {
message.success('支付成功!');
setPaySuccess(true);
// 这里可以再次调用后端查询接口确认状态,但通常 JSAPI 回调 ok 即可认为成功
// 为了保险,可以去轮询一下后端状态,或者直接展示成功页
} else if (res.err_msg == "get_brand_wcpay_request:cancel") {
message.info('支付已取消');
} else {
message.error('支付失败,请重试');
console.error('WeChat Pay Error:', res);
}
}
);
};
if (typeof window.WeixinJSBridge == "undefined") {
if (document.addEventListener) {
document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
} else if (document.attachEvent) {
document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
}
} else {
onBridgeReady();
}
} catch (error) {
console.error(error);
if (error.response && error.response.data && error.response.data.error) {
message.error(error.response.data.error);
} else {
message.error('支付发起失败,请稍后重试');
}
setPaying(false);
}
};
if (loading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
if (paySuccess) {
return (
<div className="payment-container" style={{ borderColor: '#00b96b' }}>
<Result
status="success"
icon={<CheckCircleOutlined style={{ color: '#00b96b' }} />}
title={<span style={{ color: '#fff' }}>支付成功</span>}
subTitle={<span style={{ color: '#888' }}>订单 {orderId} 已完成支付我们将尽快为您发货</span>}
extra={[
<Button type="primary" key="home" onClick={() => navigate('/')}>
返回首页
</Button>,
]}
/>
</div>
);
}
return (
<div className="payment-container">
<div className="payment-title">收银台</div>
{order ? (
<>
<div className="payment-amount">¥{order.total_price}</div>
<div className="payment-info">
<p><strong>订单编号</strong> {order.id}</p>
<p><strong>商品名称</strong> {order.config_name || 'AI 硬件设备'}</p>
<p><strong>收货人</strong> {order.customer_name}</p>
</div>
</>
) : (
<div className="payment-info">
<p>订单 ID: {orderId}</p>
<p>无法加载详情但您可以尝试支付</p>
</div>
)}
<div style={{ color: '#fff', marginBottom: 15, textAlign: 'left' }}>选择支付方式</div>
<div className="payment-method">
<div
className={`payment-method-item ${paymentMethod === 'wechat' ? 'active' : ''}`}
onClick={() => setPaymentMethod('wechat')}
>
<WechatOutlined style={{ color: '#09BB07', fontSize: 24, verticalAlign: 'middle', marginRight: 8 }} />
微信支付
</div>
<div
className={`payment-method-item ${paymentMethod === 'alipay' ? 'active' : ''}`}
onClick={() => setPaymentMethod('alipay')}
>
<AlipayCircleOutlined style={{ color: '#1677FF', fontSize: 24, verticalAlign: 'middle', marginRight: 8 }} />
支付宝
</div>
</div>
{paying && (
<div style={{ margin: '20px 0', padding: 20, background: '#fff', borderRadius: 8, display: 'inline-block' }}>
<QRCode value={`mock-payment-${orderId}`} bordered={false} />
<p style={{ color: '#000', marginTop: 10 }}>请扫码支付 (模拟)</p>
</div>
)}
{!paying && (
<Button
type="primary"
size="large"
block
onClick={handlePay}
style={{ height: 50, fontSize: 18, background: paymentMethod === 'wechat' ? '#09BB07' : '#1677FF' }}
>
立即支付
</Button>
)}
</div>
);
};
export default Payment;

View File

@@ -0,0 +1,33 @@
.product-detail-container {
color: #fff;
}
.feature-section {
padding: 60px 0;
border-bottom: 1px solid #303030;
text-align: center;
}
.feature-title {
font-size: 32px;
font-weight: bold;
margin-bottom: 20px;
color: #00b96b;
}
.feature-desc {
font-size: 18px;
color: #888;
max-width: 800px;
margin: 0 auto;
}
.spec-tag {
background: rgba(0, 185, 107, 0.1);
border: 1px solid #00b96b;
color: #00b96b;
padding: 5px 15px;
border-radius: 4px;
margin-right: 10px;
display: inline-block;
}

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Row, Col, Tag, Statistic, Modal, Form, Input, InputNumber, message, Spin, Descriptions } from 'antd';
import { ShoppingCartOutlined, SafetyCertificateOutlined, ThunderboltOutlined, EyeOutlined, StarOutlined } from '@ant-design/icons';
import { getConfigs, createOrder } from '../api';
import ModelViewer from '../components/ModelViewer';
import './ProductDetail.css';
const ProductDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const refCode = searchParams.get('ref');
useEffect(() => {
fetchProduct();
}, [id]);
const fetchProduct = async () => {
try {
const response = await getConfigs();
const found = response.data.find(p => String(p.id) === id);
if (found) {
setProduct(found);
} else {
message.error('未找到该产品');
navigate('/');
}
} catch (error) {
console.error('Failed to fetch product:', error);
message.error('加载失败');
} finally {
setLoading(false);
}
};
const handleBuy = async (values) => {
setSubmitting(true);
try {
const orderData = {
config: product.id,
quantity: values.quantity,
customer_name: values.customer_name,
phone_number: values.phone_number,
shipping_address: values.shipping_address,
ref_code: refCode
};
const response = await createOrder(orderData);
message.success('订单创建成功');
navigate(`/payment/${response.data.id}`);
} catch (error) {
console.error(error);
message.error('创建订单失败,请检查填写信息');
} finally {
setSubmitting(false);
}
};
const getModelPaths = (p) => {
if (!p) return null;
const text = (p.name + p.description).toLowerCase();
if (text.includes('mini')) {
return { obj: '/3dmimi/3D_PCB_V3-mini.obj', mtl: '/3dmimi/3D_PCB_V3-mini.mtl' };
} else if (text.includes('v2')) {
return { obj: '/3dV2/xiaoliangV2.obj', mtl: '/3dV2/xiaoliangV2.mtl' };
} else if (text.includes('vision') || text.includes('视觉') || text.includes('camera')) {
return { obj: '/3dmodo/xiaoliang1.obj', mtl: '/3dmodo/xiaoliang1.mtl' };
}
return null;
};
const modelPaths = getModelPaths(product);
const renderIcon = (feature) => {
if (feature.display_icon) {
return <img src={feature.display_icon} alt={feature.title} style={{ width: 60, height: 60, objectFit: 'contain', marginBottom: 20 }} />;
}
const iconProps = { style: { fontSize: 60, color: '#00b96b', marginBottom: 20 } };
switch(feature.icon_name) {
case 'SafetyCertificate':
return <SafetyCertificateOutlined {...iconProps} />;
case 'Eye':
return <EyeOutlined {...iconProps} style={{ ...iconProps.style, color: '#1890ff' }} />;
case 'Thunderbolt':
return <ThunderboltOutlined {...iconProps} style={{ ...iconProps.style, color: '#faad14' }} />;
default:
return <StarOutlined {...iconProps} />;
}
};
if (loading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
if (!product) return null;
return (
<div className="product-detail-container" style={{ paddingBottom: '60px' }}>
{/* Hero Section */}
<Row gutter={40} align="middle" style={{ minHeight: '60vh' }}>
<Col xs={24} md={12}>
<div style={{
height: 400,
background: 'radial-gradient(circle, #2a2a2a 0%, #000 100%)',
borderRadius: 20,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: '1px solid #333',
overflow: 'hidden'
}}>
{modelPaths ? (
<ModelViewer objPath={modelPaths.obj} mtlPath={modelPaths.mtl} />
) : (
<ThunderboltOutlined style={{ fontSize: 120, color: '#00b96b' }} />
)}
</div>
</Col>
<Col xs={24} md={12}>
<h1 style={{ fontSize: 48, fontWeight: 'bold', color: '#fff' }}>{product.name}</h1>
<p style={{ fontSize: 20, color: '#888', margin: '20px 0' }}>{product.description}</p>
<div style={{ marginBottom: 30 }}>
<span className="spec-tag">{product.chip_type}</span>
{product.has_camera && <span className="spec-tag">高清摄像头</span>}
{product.has_microphone && <span className="spec-tag">阵列麦克风</span>}
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 20, marginBottom: 40 }}>
<Statistic title="售价" value={product.price} prefix="¥" valueStyle={{ color: '#00b96b', fontSize: 36 }} titleStyle={{ color: '#888' }} />
</div>
<Button type="primary" size="large" icon={<ShoppingCartOutlined />} onClick={() => setIsModalOpen(true)} style={{ height: 50, padding: '0 40px', fontSize: 18 }}>
立即购买
</Button>
</Col>
</Row>
{/* Feature Section */}
<div style={{ marginTop: 100 }}>
{product.features && product.features.length > 0 ? (
product.features.map((feature, index) => (
<div className="feature-section" key={index}>
{renderIcon(feature)}
<div className="feature-title">{feature.title}</div>
<div className="feature-desc">{feature.description}</div>
</div>
))
) : (
// Fallback content if no features are configured
<>
<div className="feature-section">
<SafetyCertificateOutlined style={{ fontSize: 60, color: '#00b96b', marginBottom: 20 }} />
<div className="feature-title">工业级安全标准</div>
<div className="feature-desc">
采用军工级加密芯片保障您的数据隐私安全无论是边缘计算还是云端同步全程加密传输 AI 应用无后顾之忧
</div>
</div>
<div className="feature-section">
<EyeOutlined style={{ fontSize: 60, color: '#1890ff', marginBottom: 20 }} />
<div className="feature-title">超清视觉感知</div>
<div className="feature-desc">
搭载 4K 高清摄像头与 AI 视觉算法实时捕捉每一个细节支持人脸识别物体检测姿态分析等多种视觉任务
</div>
</div>
<div className="feature-section">
<ThunderboltOutlined style={{ fontSize: 60, color: '#faad14', marginBottom: 20 }} />
<div className="feature-title">极致性能释放</div>
<div className="feature-desc">
{product.chip_type} 强劲核心提供高达 XX TOPS 的算力支持低功耗设计满足 24 小时全天候运行需求
</div>
</div>
</>
)}
{product.display_detail_image ? (
<div style={{ margin: '60px 0', width: '100%', overflow: 'hidden', borderRadius: 12 }}>
<img src={product.display_detail_image} alt="产品详情" style={{ width: '100%', display: 'block' }} />
</div>
) : (
<div style={{ margin: '60px 0', height: 800, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#333', fontSize: 24, border: '1px dashed #333' }}>
产品详情长图展示区域 (请在后台配置)
</div>
)}
</div>
{/* Order Modal */}
<Modal
title="填写收货信息"
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
onFinish={handleBuy}
initialValues={{ quantity: 1 }}
>
<Form.Item label="购买数量" name="quantity" rules={[{ required: true }]}>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="收货人姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="张三" />
</Form.Item>
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
<Input placeholder="13800000000" />
</Form.Item>
<Form.Item label="收货地址" name="shipping_address" rules={[{ required: true, message: '请输入地址' }]}>
<Input.TextArea rows={3} placeholder="北京市..." />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>提交订单</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default ProductDetail;

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message, Statistic } from 'antd';
import { ArrowLeftOutlined, ClockCircleOutlined, GiftOutlined, ShoppingCartOutlined } from '@ant-design/icons';
import { getServiceDetail, createServiceOrder } from '../api';
import { motion } from 'framer-motion';
const { Title, Paragraph } = Typography;
const ServiceDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [service, setService] = useState(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await getServiceDetail(id);
setService(response.data);
} catch (error) {
console.error("Failed to fetch service detail:", error);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [id]);
const handlePurchase = async (values) => {
setSubmitting(true);
try {
const orderData = {
service: service.id,
customer_name: values.customer_name,
company_name: values.company_name,
phone_number: values.phone_number,
email: values.email,
requirements: values.requirements
};
await createServiceOrder(orderData);
message.success('需求已提交,我们的销售顾问将尽快与您联系!');
setIsModalOpen(false);
} catch (error) {
console.error(error);
message.error('提交失败,请重试');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="Loading..." />
</div>
);
}
if (!service) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Empty description="Service not found" />
<Button type="primary" onClick={() => navigate('/services')} style={{ marginTop: 20 }}>
Return to Services
</Button>
</div>
);
}
return (
<div style={{ padding: '20px 0' }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
style={{ color: '#fff', marginBottom: 20 }}
onClick={() => navigate('/services')}
>
返回服务列表
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Row gutter={[40, 40]}>
<Col xs={24} md={16}>
<div style={{ textAlign: 'left', marginBottom: 40 }}>
<Title level={1} style={{ color: '#fff' }}>
{service.title}
</Title>
<Paragraph style={{ color: '#888', fontSize: 18 }}>
{service.description}
</Paragraph>
<div style={{ marginTop: 30, background: 'rgba(255,255,255,0.05)', padding: 20, borderRadius: 12 }}>
<Title level={4} style={{ color: '#fff' }}>服务详情</Title>
<Descriptions column={1} labelStyle={{ color: '#888' }} contentStyle={{ color: '#fff' }}>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><ClockCircleOutlined style={{ marginRight: 8 }} /> 交付周期</span>}>
{service.delivery_time || '待沟通'}
</Descriptions.Item>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><GiftOutlined style={{ marginRight: 8 }} /> 交付内容</span>}>
{service.delivery_content || '根据需求定制'}
</Descriptions.Item>
</Descriptions>
</div>
</div>
{service.display_detail_image ? (
<div style={{
width: '100%',
background: '#111',
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 0 30px ${service.color}22`,
border: `1px solid ${service.color}33`
}}>
<img
src={service.display_detail_image}
alt={service.title}
style={{ width: '100%', display: 'block' }}
/>
</div>
) : (
<div style={{ textAlign: 'center', padding: 100, background: '#111', borderRadius: 12, color: '#666' }}>
暂无详情图片
</div>
)}
</Col>
<Col xs={24} md={8}>
<div style={{ position: 'sticky', top: 100 }}>
<div style={{
background: '#1f1f1f',
padding: 30,
borderRadius: 16,
border: `1px solid ${service.color}44`,
boxShadow: `0 0 20px ${service.color}11`
}}>
<Title level={3} style={{ color: '#fff', marginTop: 0 }}>服务报价</Title>
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 20 }}>
<span style={{ fontSize: 36, color: service.color, fontWeight: 'bold' }}>¥{service.price}</span>
<span style={{ color: '#888', marginLeft: 8 }}>/ {service.unit} </span>
</div>
<div style={{ marginBottom: 20 }}>
{service.features_list && service.features_list.map((feat, i) => (
<Tag color={service.color} key={i} style={{ marginBottom: 8, padding: '4px 10px' }}>
{feat}
</Tag>
))}
</div>
<Button
type="primary"
size="large"
block
icon={<ShoppingCartOutlined />}
style={{
height: 50,
background: service.color,
borderColor: service.color,
color: '#000',
fontWeight: 'bold'
}}
onClick={() => setIsModalOpen(true)}
>
立即咨询 / 购买
</Button>
<p style={{ color: '#666', marginTop: 15, fontSize: 12, textAlign: 'center' }}>
* 具体价格可能因需求复杂度而异提交需求后我们将提供详细报价单
</p>
</div>
</div>
</Col>
</Row>
</motion.div>
{/* Purchase Modal */}
<Modal
title={`咨询/购买 - ${service.title}`}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
destroyOnHidden
>
<p style={{ marginBottom: 20, color: '#666' }}>请填写您的联系方式和需求我们的技术顾问将在 24 小时内与您联系</p>
<Form
form={form}
layout="vertical"
onFinish={handlePurchase}
>
<Form.Item label="您的姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="例如:张经理" />
</Form.Item>
<Form.Item label="公司/机构名称" name="company_name">
<Input placeholder="例如:某某科技有限公司" />
</Form.Item>
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
<Input placeholder="13800000000" />
</Form.Item>
<Form.Item label="电子邮箱" name="email" rules={[{ type: 'email' }]}>
<Input placeholder="example@company.com" />
</Form.Item>
<Form.Item label="需求描述" name="requirements">
<Input.TextArea rows={4} placeholder="请简单描述您的业务场景或具体需求..." />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>提交需求</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default ServiceDetail;

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})