commit b8024da3dce006569ad20f0da7a16cb541620741 Author: xiaoma Date: Mon Feb 2 19:10:34 2026 +0800 fix: 3D Show diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c1ac06 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffe957c --- /dev/null +++ b/README.md @@ -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/) + +--- + +**⭐ 如果这个项目对您有帮助,请给我们一个星标!** \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..104b25a --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/backend/config/asgi.py @@ -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() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..6229f8b --- /dev/null +++ b/backend/config/settings.py @@ -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)", + }, + }, +} diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..4f02810 --- /dev/null +++ b/backend/config/urls.py @@ -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) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/backend/config/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/populate_db.py b/backend/populate_db.py new file mode 100644 index 0000000..10f9386 --- /dev/null +++ b/backend/populate_db.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f6cc055 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/shop/__init__.py b/backend/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shop/admin.py b/backend/shop/admin.py new file mode 100644 index 0000000..99780ed --- /dev/null +++ b/backend/shop/admin.py @@ -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('打开推广链接', 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_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',) + }), + ) diff --git a/backend/shop/apps.py b/backend/shop/apps.py new file mode 100644 index 0000000..a5c0262 --- /dev/null +++ b/backend/shop/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + name = 'shop' diff --git a/backend/shop/migrations/0001_initial.py b/backend/shop/migrations/0001_initial.py new file mode 100644 index 0000000..5d3d824 --- /dev/null +++ b/backend/shop/migrations/0001_initial.py @@ -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': '订单列表', + }, + ), + ] diff --git a/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py b/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py new file mode 100644 index 0000000..d8aafc5 --- /dev/null +++ b/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py @@ -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='发货地址'), + ), + ] diff --git a/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py b/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py new file mode 100644 index 0000000..8968035 --- /dev/null +++ b/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py @@ -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='所属销售员'), + ), + ] diff --git a/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py b/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py new file mode 100644 index 0000000..5d9f8e3 --- /dev/null +++ b/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py @@ -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'), + ), + ] diff --git a/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py b/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py new file mode 100644 index 0000000..2c70273 --- /dev/null +++ b/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py @@ -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'), + ), + ] diff --git a/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py b/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py new file mode 100644 index 0000000..166887b --- /dev/null +++ b/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py @@ -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='图标 (上传)'), + ), + ] diff --git a/backend/shop/migrations/0007_productfeature.py b/backend/shop/migrations/0007_productfeature.py new file mode 100644 index 0000000..a8e0c21 --- /dev/null +++ b/backend/shop/migrations/0007_productfeature.py @@ -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'], + }, + ), + ] diff --git a/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py b/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py new file mode 100644 index 0000000..bf9889a --- /dev/null +++ b/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py @@ -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': '服务订单列表', + }, + ), + ] diff --git a/backend/shop/migrations/__init__.py b/backend/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shop/models.py b/backend/shop/models.py new file mode 100644 index 0000000..b7395d0 --- /dev/null +++ b/backend/shop/models.py @@ -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体验管理" diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py new file mode 100644 index 0000000..e7b5635 --- /dev/null +++ b/backend/shop/serializers.py @@ -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) diff --git a/backend/shop/templates/shop/order_check.html b/backend/shop/templates/shop/order_check.html new file mode 100644 index 0000000..c243520 --- /dev/null +++ b/backend/shop/templates/shop/order_check.html @@ -0,0 +1,151 @@ + + + + + + 订单查询 - 量迹AI硬件 + + + +
+

订单状态查询

+
+ + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/backend/shop/tests.py b/backend/shop/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/shop/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/shop/urls.py b/backend/shop/urls.py new file mode 100644 index 0000000..ebf41eb --- /dev/null +++ b/backend/shop/urls.py @@ -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'), +] diff --git a/backend/shop/views.py b/backend/shop/views.py new file mode 100644 index 0000000..26e9016 --- /dev/null +++ b/backend/shop/views.py @@ -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': '支付成功'}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1debbb3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c21d2dd --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -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_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f80eaeb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Quant Speed + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5757c39 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/big_logo.png b/frontend/public/big_logo.png new file mode 100644 index 0000000..2242f36 Binary files /dev/null and b/frontend/public/big_logo.png differ diff --git a/frontend/public/gXEu5E01.svg b/frontend/public/gXEu5E01.svg new file mode 100644 index 0000000..6768690 --- /dev/null +++ b/frontend/public/gXEu5E01.svg @@ -0,0 +1,106 @@ + + + + +Created by potrace 1.10, written by Peter Selinger 2001-2011 + + + + + + + + + + + + + + + + + diff --git a/frontend/public/liangji_black.png b/frontend/public/liangji_black.png new file mode 100644 index 0000000..c2396e0 Binary files /dev/null and b/frontend/public/liangji_black.png differ diff --git a/frontend/public/liangji_logo.svg b/frontend/public/liangji_logo.svg new file mode 100644 index 0000000..25f8a5d --- /dev/null +++ b/frontend/public/liangji_logo.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + QUANT SPEED + + \ No newline at end of file diff --git a/frontend/public/liangji_white.png b/frontend/public/liangji_white.png new file mode 100644 index 0000000..ddf0cd6 Binary files /dev/null and b/frontend/public/liangji_white.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..7ca6634 --- /dev/null +++ b/frontend/src/App.css @@ -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; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..79ed61d --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..29ea1d0 --- /dev/null +++ b/frontend/src/api.js @@ -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; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..59c82ae --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -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: , + label: 'AI 硬件', + }, + { + key: '/services', + icon: , + label: 'AI 服务', + }, + { + key: '/ar', + icon: , + label: 'AR 体验', + }, + { + key: 'more', + label: '...', + }, + ]; + + const handleMenuClick = (key) => { + if (key === 'more') return; + navigate(key); + setMobileMenuOpen(false); + }; + + return ( + + + +
+
+ navigate('/')} + > + Quant Speed Logo + + + {/* Desktop Menu */} +
+ handleMenuClick(e.key)} + style={{ + background: 'transparent', + borderBottom: 'none', + display: 'flex', + justifyContent: 'flex-end', + minWidth: '400px' + }} + /> +
+ + + {/* Mobile Menu Button */} +
+
+ + {/* Mobile Drawer Menu */} + 导航菜单} + placement="right" + onClose={() => setMobileMenuOpen(false)} + open={mobileMenuOpen} + styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }} + > + handleMenuClick(e.key)} + style={{ background: 'transparent', borderRight: 'none' }} + /> + + + +
+ + + {children} + + +
+
+ +
+ Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech +
+ + + ); +}; + +export default Layout; diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx new file mode 100644 index 0000000..d766735 --- /dev/null +++ b/frontend/src/components/ModelViewer.jsx @@ -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 ; +}; + +const LoadingOverlay = () => { + const { progress, active } = useProgress(); + if (!active) return null; + + return ( +
+
+ +
+ {progress.toFixed(0)}% Loading +
+
+
+ ); +}; + +const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; + +export default ModelViewer; diff --git a/frontend/src/components/ParticleBackground.jsx b/frontend/src/components/ParticleBackground.jsx new file mode 100644 index 0000000..3ac7175 --- /dev/null +++ b/frontend/src/components/ParticleBackground.jsx @@ -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 ; +}; + +export default ParticleBackground; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..922e120 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/src/pages/AIServices.jsx b/frontend/src/pages/AIServices.jsx new file mode 100644 index 0000000..a2a7f3d --- /dev/null +++ b/frontend/src/pages/AIServices.jsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+ + + AI 全栈<span style={{ color: '#00f0ff', textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>解决方案</span> + + + + 从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。 + +
+ + + {services.map((item, index) => ( + + navigate(`/services/${item.id}`)} + style={{ cursor: 'pointer' }} + > +
+ {/* HUD 装饰线 */} +
+
+
+
+ +
+
+ {item.display_icon ? ( + {item.title} + ) : ( +
+ )} +
+

{item.title}

+
+ +

{item.description}

+ +
+ {item.features_list && item.features_list.map((feat, i) => ( +
+
+ {feat} +
+ ))} +
+ + +
+ + + ))} + + + {/* 动态流程图模拟 */} + + 服务流程 + + {['需求分析', '数据准备', '模型训练', '测试验证', '私有化部署'].map((step, i) => ( + +
+ {step} + {/* 简单的连接线模拟 */} + {i < 4 && ( +
+ )} +
+ + ))} + + + +
+ ); +}; + +export default AIServices; diff --git a/frontend/src/pages/ARExperience.jsx b/frontend/src/pages/ARExperience.jsx new file mode 100644 index 0000000..6dcc837 --- /dev/null +++ b/frontend/src/pages/ARExperience.jsx @@ -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
; + + return ( +
+
+ + AR <span style={{ color: '#00f0ff' }}>UNIVERSE</span> + + + 探索全息增强现实体验。请佩戴您的设备,或使用移动端摄像头扫描空间。 + +
+ + {arServices.length === 0 ? ( +
+ 暂无 AR 体验内容} /> +
+ ) : ( + + {arServices.map((item, index) => ( + + +
+
+ {item.display_cover_image ? ( + {item.title} + ) : ( + + )} +
+
+

{item.title}

+

{item.description}

+ +
+
+
+ + ))} +
+ )} + + {/* 装饰性背景 */} +
+ +
+ +
+ ); +}; + +export default ARExperience; diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css new file mode 100644 index 0000000..095bb0f --- /dev/null +++ b/frontend/src/pages/Home.css @@ -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; +} diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..376f213 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+ + + + + <span className="neon-text-green">{typedText}</span> + {!isTypingComplete && <span className="cursor-blink">|</span>} + + + + 量迹 AI 硬件为您提供最强大的边缘计算能力,搭载最新一代神经处理单元,赋能您的每一个创意。 + + +
+ +
+ + {products.map((product, index) => ( + + + + + + +
+ } + onClick={() => navigate(`/product/${product.id}`)} + > +
{product.name}
+
+ {product.description} +
+
+ {product.chip_type} + {product.has_camera && Camera} +
+
+
¥{product.price}
+
+ + + + ))} + +
+ +
+ ); +}; + +export default Home; diff --git a/frontend/src/pages/Payment.css b/frontend/src/pages/Payment.css new file mode 100644 index 0000000..2da9ed7 --- /dev/null +++ b/frontend/src/pages/Payment.css @@ -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); +} diff --git a/frontend/src/pages/Payment.jsx b/frontend/src/pages/Payment.jsx new file mode 100644 index 0000000..500665f --- /dev/null +++ b/frontend/src/pages/Payment.jsx @@ -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
; + + if (paySuccess) { + return ( +
+ } + title={支付成功} + subTitle={订单 {orderId} 已完成支付,我们将尽快为您发货。} + extra={[ + , + ]} + /> +
+ ); + } + + return ( +
+
收银台
+ + {order ? ( + <> +
¥{order.total_price}
+
+

订单编号: {order.id}

+

商品名称: {order.config_name || 'AI 硬件设备'}

+

收货人: {order.customer_name}

+
+ + ) : ( +
+

订单 ID: {orderId}

+

无法加载详情,但您可以尝试支付。

+
+ )} + +
选择支付方式:
+
+
setPaymentMethod('wechat')} + > + + 微信支付 +
+
setPaymentMethod('alipay')} + > + + 支付宝 +
+
+ + {paying && ( +
+ +

请扫码支付 (模拟)

+
+ )} + + {!paying && ( + + )} +
+ ); +}; + +export default Payment; diff --git a/frontend/src/pages/ProductDetail.css b/frontend/src/pages/ProductDetail.css new file mode 100644 index 0000000..7392d3d --- /dev/null +++ b/frontend/src/pages/ProductDetail.css @@ -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; +} diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx new file mode 100644 index 0000000..e212373 --- /dev/null +++ b/frontend/src/pages/ProductDetail.jsx @@ -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 {feature.title}; + } + + const iconProps = { style: { fontSize: 60, color: '#00b96b', marginBottom: 20 } }; + + switch(feature.icon_name) { + case 'SafetyCertificate': + return ; + case 'Eye': + return ; + case 'Thunderbolt': + return ; + default: + return ; + } + }; + + if (loading) return
; + if (!product) return null; + + return ( +
+ {/* Hero Section */} + + +
+ {modelPaths ? ( + + ) : ( + + )} +
+ + +

{product.name}

+

{product.description}

+ +
+ {product.chip_type} + {product.has_camera && 高清摄像头} + {product.has_microphone && 阵列麦克风} +
+ +
+ +
+ + + +
+ + {/* Feature Section */} +
+ {product.features && product.features.length > 0 ? ( + product.features.map((feature, index) => ( +
+ {renderIcon(feature)} +
{feature.title}
+
{feature.description}
+
+ )) + ) : ( + // Fallback content if no features are configured + <> +
+ +
工业级安全标准
+
+ 采用军工级加密芯片,保障您的数据隐私安全。无论是边缘计算还是云端同步,全程加密传输,让 AI 应用无后顾之忧。 +
+
+ +
+ +
超清视觉感知
+
+ 搭载 4K 高清摄像头与 AI 视觉算法,实时捕捉每一个细节。支持人脸识别、物体检测、姿态分析等多种视觉任务。 +
+
+ +
+ +
极致性能释放
+
+ {product.chip_type} 强劲核心,提供高达 XX TOPS 的算力支持。低功耗设计,满足 24 小时全天候运行需求。 +
+
+ + )} + + {product.display_detail_image ? ( +
+ 产品详情 +
+ ) : ( +
+ 产品详情长图展示区域 (请在后台配置) +
+ )} +
+ + {/* Order Modal */} + setIsModalOpen(false)} + footer={null} + destroyOnHidden + > +
+ + + + + + + + + + + + + +
+ + +
+
+
+
+ ); +}; + +export default ProductDetail; diff --git a/frontend/src/pages/ServiceDetail.jsx b/frontend/src/pages/ServiceDetail.jsx new file mode 100644 index 0000000..e8f7ed9 --- /dev/null +++ b/frontend/src/pages/ServiceDetail.jsx @@ -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 ( +
+ +
+ ); + } + + if (!service) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + + + +
+ + {service.title} + + + {service.description} + + +
+ 服务详情 + + 交付周期}> + {service.delivery_time || '待沟通'} + + 交付内容}> + {service.delivery_content || '根据需求定制'} + + +
+
+ + {service.display_detail_image ? ( +
+ {service.title} +
+ ) : ( +
+ 暂无详情图片 +
+ )} + + + +
+
+ 服务报价 +
+ ¥{service.price} + / {service.unit} 起 +
+ +
+ {service.features_list && service.features_list.map((feat, i) => ( + + {feat} + + ))} +
+ + +

+ * 具体价格可能因需求复杂度而异,提交需求后我们将提供详细报价单 +

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

请填写您的联系方式和需求,我们的技术顾问将在 24 小时内与您联系。

+
+ + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ ); +}; + +export default ServiceDetail; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})