This commit is contained in:
jeremygan2021
2026-02-11 03:00:38 +08:00
parent c3b4373c94
commit 96d5598fb5
57 changed files with 2239 additions and 577 deletions

View File

@@ -12,7 +12,7 @@ links = [
"admin:shop_distributor_changelist",
"admin:shop_esp32config_changelist",
"admin:shop_service_changelist",
"admin:shop_arservice_changelist",
"admin:shop_vbcourse_changelist",
"admin:shop_order_changelist",
"admin:shop_serviceorder_changelist",
"admin:shop_withdrawal_changelist",

View File

@@ -232,9 +232,9 @@ UNFOLD = {
"link": reverse_lazy("admin:shop_service_changelist"),
},
{
"title": "AR体验",
"icon": "view_in_ar",
"link": reverse_lazy("admin:shop_arservice_changelist"),
"title": "VB课程",
"icon": "school",
"link": reverse_lazy("admin:shop_vbcourse_changelist"),
},
],
},

Binary file not shown.

View File

@@ -4,7 +4,7 @@ from django.db.models import Sum
from django import forms
from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, ARService, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VBCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder
import qrcode
from io import BytesIO
import base64
@@ -19,11 +19,11 @@ class ExternalUploadWidget(forms.URLInput):
super().__init__(*args, **kwargs)
self.upload_url = upload_url
self.attrs.update({
'class': 'upload-url-input',
'class': 'upload-url-input vTextField',
'data-upload-url': upload_url,
'data-accept': accept,
'readonly': 'readonly',
'placeholder': '上传文件后自动生成URL'
'placeholder': '上传文件后自动生成URL',
'style': 'width: 100%;'
})
class Media:
@@ -141,18 +141,26 @@ class ServiceOrderAdmin(ModelAdmin):
}),
)
@admin.register(ARService)
class ARServiceAdmin(ModelAdmin):
list_display = ('title', 'created_at')
search_fields = ('title', 'description')
@admin.register(VBCourse)
class VBCourseAdmin(ModelAdmin):
list_display = ('title', 'course_type', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at')
search_fields = ('title', 'description', 'instructor', 'tag')
list_filter = ('course_type', 'instructor', 'tag')
fieldsets = (
('基本信息', {
'fields': ('title', 'description')
'fields': ('title', 'description', 'course_type', 'tag')
}),
('封面/长图', {
('课程详情', {
'fields': ('instructor', 'duration', 'lesson_count')
}),
('封面', {
'fields': ('cover_image', 'cover_image_url'),
'description': '图片上传和URL二选一优先使用URL'
}),
('详情页长图', {
'fields': ('detail_image', 'detail_image_url'),
'description': '图片上传和URL二选一优先使用URL'
}),
)
@admin.register(Salesperson)

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.1 on 2026-02-10 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0017_withdrawal'),
]
operations = [
migrations.CreateModel(
name='VBCourse',
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='课程简介')),
('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程')], default='software', max_length=20, verbose_name='课程类型')),
('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')),
('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')),
('instructor', models.CharField(default='VB讲师', max_length=50, verbose_name='讲师')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/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': 'VB课程',
'verbose_name_plural': 'VB课程管理',
},
),
migrations.DeleteModel(
name='ARService',
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-02-10 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0018_vbcourse_delete_arservice'),
]
operations = [
migrations.AddField(
model_name='vbcourse',
name='detail_image',
field=models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)'),
),
migrations.AddField(
model_name='vbcourse',
name='detail_image_url',
field=models.URLField(blank=True, help_text='如果填写了URL将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
),
migrations.AddField(
model_name='vbcourse',
name='tag',
field=models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-10 18:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more'),
]
operations = [
migrations.AlterField(
model_name='vbcourse',
name='course_type',
field=models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型'),
),
]

View File

@@ -312,19 +312,36 @@ class ServiceOrder(models.Model):
verbose_name_plural = "服务订单列表"
class ARService(models.Model):
class VBCourse(models.Model):
"""
AR体验服务模型
VB Coding 课程模型
"""
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)")
COURSE_TYPE_CHOICES = (
('software', '软件课程'),
('hardware', '硬件课程'),
('incubation', '产品商业孵化'),
)
title = models.CharField(max_length=100, verbose_name="课程名称")
description = models.TextField(verbose_name="课程简介")
course_type = models.CharField(max_length=20, choices=COURSE_TYPE_CHOICES, default='software', verbose_name="课程类型")
duration = models.CharField(max_length=50, verbose_name="课程时长", help_text="例如: 30分钟", default="30分钟")
lesson_count = models.IntegerField(default=1, verbose_name="课时数量")
instructor = models.CharField(max_length=50, verbose_name="讲师", default="VB讲师")
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
cover_image = models.ImageField(upload_to='courses/covers/', blank=True, null=True, verbose_name="封面图 (上传)")
cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)")
detail_image = models.ImageField(upload_to='courses/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL将优先使用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体验管理"
verbose_name = "VB课程"
verbose_name_plural = "VB课程管理"

View File

@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal
from .models import ESP32Config, Order, Salesperson, Service, VBCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal
class WeChatUserSerializer(serializers.ModelSerializer):
class Meta:
@@ -101,14 +101,16 @@ class ServiceOrderSerializer(serializers.ModelSerializer):
return super().create(validated_data)
class ARServiceSerializer(serializers.ModelSerializer):
class VBCourseSerializer(serializers.ModelSerializer):
"""
AR服务序列化器
VB课程序列化器
"""
display_cover_image = serializers.SerializerMethodField()
display_detail_image = serializers.SerializerMethodField()
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
class Meta:
model = ARService
model = VBCourse
fields = '__all__'
def get_display_cover_image(self, obj):
@@ -118,6 +120,13 @@ class ARServiceSerializer(serializers.ModelSerializer):
return obj.cover_image.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 ESP32ConfigSerializer(serializers.ModelSerializer):
"""
ESP32配置序列化器

View File

@@ -2,7 +2,7 @@ from django.urls import path, include, re_path
from rest_framework.routers import DefaultRouter
from .views import (
ESP32ConfigViewSet, OrderViewSet, order_check_view,
ServiceViewSet, ARServiceViewSet, ServiceOrderViewSet,
ServiceViewSet, VBCourseViewSet, ServiceOrderViewSet,
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet
)
@@ -10,7 +10,7 @@ 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'courses', VBCourseViewSet)
router.register(r'service-orders', ServiceOrderViewSet)
router.register(r'distributor', DistributorViewSet, basename='distributor')

View File

@@ -5,8 +5,8 @@ from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from .models import ESP32Config, Order, WeChatPayConfig, Service, ARService, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer
from .models import ESP32Config, Order, WeChatPayConfig, Service, VBCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VBCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.contrib.auth.models import User
from wechatpayv3 import WeChatPay, WeChatPayType
@@ -508,15 +508,15 @@ def payment_finish(request):
return HttpResponse(str(e), status=500)
@extend_schema_view(
list=extend_schema(summary="获取AR服务列表", description="获取所有可用的AR服务"),
retrieve=extend_schema(summary="获取AR服务详情", description="获取指定AR服务的详细信息")
list=extend_schema(summary="获取VB课程列表", description="获取所有可用的VB课程"),
retrieve=extend_schema(summary="获取VB课程详情", description="获取指定VB课程的详细信息")
)
class ARServiceViewSet(viewsets.ReadOnlyModelViewSet):
class VBCourseViewSet(viewsets.ReadOnlyModelViewSet):
"""
AR服务列表和详情
VB课程列表和详情
"""
queryset = ARService.objects.all().order_by('-created_at')
serializer_class = ARServiceSerializer
queryset = VBCourse.objects.all().order_by('-created_at')
serializer_class = VBCourseSerializer
def order_check_view(request):
"""

View File

@@ -6,7 +6,7 @@ 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 VBCourses from './pages/VBCourses';
import MyOrders from './pages/MyOrders';
import 'antd/dist/reset.css';
import './App.css';
@@ -19,7 +19,7 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/services" element={<AIServices />} />
<Route path="/services/:id" element={<ServiceDetail />} />
<Route path="/ar" element={<ARExperience />} />
<Route path="/courses" element={<VBCourses />} />
<Route path="/my-orders" element={<MyOrders />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/payment/:orderId" element={<Payment />} />

View File

@@ -19,7 +19,7 @@ export const confirmPayment = (orderId) => api.post(`/orders/${orderId}/confirm_
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 const getVBCourses = () => api.get('/courses/');
export const sendSms = (data) => api.post('/auth/send-sms/', data);
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);

View File

@@ -34,9 +34,9 @@ const Layout = ({ children }) => {
label: 'AI 服务',
},
{
key: '/ar',
key: '/courses',
icon: <EyeOutlined />,
label: 'AR 体验',
label: 'VB 课程',
},
{
key: '/my-orders',

View File

@@ -1,28 +1,27 @@
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';
import { Button, Typography, Spin, Row, Col, Empty, Tag } from 'antd';
import { ReadOutlined, ClockCircleOutlined, UserOutlined, BookOutlined } from '@ant-design/icons';
import { getVBCourses } from '../api';
const { Title, Paragraph } = Typography;
const ARExperience = () => {
const [scanning, setScanning] = useState(true);
const [arServices, setArServices] = useState([]);
const VBCourses = () => {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAR = async () => {
const fetchCourses = async () => {
try {
const res = await getARServices();
setArServices(res.data);
const res = await getVBCourses();
setCourses(res.data);
} catch (error) {
console.error("Failed to fetch AR services:", error);
console.error("Failed to fetch VB Courses:", error);
} finally {
setLoading(false);
}
}
fetchAR();
fetchCourses();
}, []);
if (loading) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
@@ -31,20 +30,20 @@ const ARExperience = () => {
<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>
VB <span style={{ color: '#00f0ff' }}>CODING COURSES</span>
</Title>
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
探索全息增强现实体验请佩戴您的设备或使用移动端摄像头扫描空间
探索 Vibe Coding 软件与硬件课程开启您的编程之旅
</Paragraph>
</div>
{arServices.length === 0 ? (
{courses.length === 0 ? (
<div style={{ textAlign: 'center', marginTop: 100, zIndex: 2, position: 'relative' }}>
<Empty description={<span style={{ color: '#666' }}>暂无 AR 体验内容</span>} />
<Empty description={<span style={{ color: '#666' }}>暂无课程内容</span>} />
</div>
) : (
<Row gutter={[32, 32]} justify="center" style={{ padding: '0 20px', position: 'relative', zIndex: 2 }}>
{arServices.map((item, index) => (
{courses.map((item, index) => (
<Col xs={24} md={12} lg={8} key={item.id}>
<motion.div
initial={{ opacity: 0, y: 30 }}
@@ -57,20 +56,35 @@ const ARExperience = () => {
border: '1px solid rgba(0,240,255,0.2)',
borderRadius: 12,
overflow: 'hidden',
height: '100%'
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{ height: 200, background: '#000', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ height: 200, background: '#000', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{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' }} />
<ReadOutlined style={{ fontSize: 40, color: '#333' }} />
)}
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: '5px' }}>
{item.tag && (
<Tag color="volcano" style={{ marginRight: 0 }}>{item.tag}</Tag>
)}
<Tag color={item.course_type === 'hardware' ? 'purple' : 'cyan'}>
{item.course_type_display || (item.course_type === 'hardware' ? '硬件课程' : '软件课程')}
</Tag>
</div>
</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>
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column' }}>
<h3 style={{ color: '#fff', fontSize: 20, marginBottom: 10 }}>{item.title}</h3>
<div style={{ color: '#888', marginBottom: 15, fontSize: 14 }}>
<span style={{ marginRight: 15 }}><UserOutlined /> {item.instructor}</span>
<span style={{ marginRight: 15 }}><ClockCircleOutlined /> {item.duration}</span>
<span><BookOutlined /> {item.lesson_count} 课时</span>
</div>
<p style={{ color: '#aaa', marginBottom: 20, flex: 1 }}>{item.description}</p>
<Button type="primary" block ghost style={{ borderColor: '#00f0ff', color: '#00f0ff' }}>
启动体验
开始学习
</Button>
</div>
</div>
@@ -108,4 +122,4 @@ const ARExperience = () => {
);
};
export default ARExperience;
export default VBCourses;

View File

@@ -22,7 +22,7 @@ const config = {
framework: 'react',
compiler: 'webpack5',
cache: {
enable: false // Disable cache to avoid potential issues
enable: true // Enable cache for better build performance
},
mini: {
postcss: {

View File

@@ -15,15 +15,18 @@ export const getServices = () => request({ url: '/services/' })
export const getServiceDetail = (id: number) => request({ url: `/services/${id}/` })
export const createServiceOrder = (data: any) => request({ url: '/service-orders/', method: 'POST', data })
// AR Services
export const getARServices = () => request({ url: '/ar/' })
export const getARServiceDetail = (id: number) => request({ url: `/ar/${id}/` })
// VB Courses
export const getVBCourses = () => request({ url: '/courses/' })
export const getVBCourseDetail = (id: number) => request({ url: `/courses/${id}/` })
// Distributor
export const distributorRegister = (data: any) => request({ url: '/distributor/register/', method: 'POST', data })
export const distributorInfo = () => request({ url: '/distributor/info/' })
export const distributorInvite = () => request({ url: '/distributor/invite/', method: 'POST' })
export const distributorWithdraw = (amount: number) => request({ url: '/distributor/withdraw/', method: 'POST', data: { amount } })
// TODO: Verify if these exist in the API docs
// export const distributorTeam = () => request({ url: '/distributor/team/' })
// export const distributorHistory = () => request({ url: '/distributor/history/' })
// User
export const updateUserInfo = (data: any) => request({ url: '/wechat/update/', method: 'POST', data })

View File

@@ -3,8 +3,8 @@ export default defineAppConfig({
'pages/index/index',
'pages/services/index',
'pages/services/detail',
'pages/ar/index',
'pages/ar/detail',
'pages/courses/index',
'pages/courses/detail',
'pages/goods/detail',
'pages/cart/cart',
'pages/order/checkout',
@@ -25,14 +25,15 @@ export default defineAppConfig({
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarBackgroundColor: '#000000',
navigationBarTitleText: 'Quant Speed Market',
navigationBarTextStyle: 'black'
navigationBarTextStyle: 'white'
},
tabBar: {
color: "#999",
selectedColor: "#333",
backgroundColor: "#fff",
color: "#666666",
selectedColor: "#00b96b",
backgroundColor: "#000000",
borderStyle: "black",
list: [
{
pagePath: "pages/index/index",
@@ -43,13 +44,19 @@ export default defineAppConfig({
{
pagePath: "pages/services/index",
text: "AI服务",
iconPath: "./assets/cart.png", // Using cart icon as placeholder if no other icon available
selectedIconPath: "./assets/cart_active.png"
iconPath: "./assets/AI_service.png",
selectedIconPath: "./assets/AI_service_active.png"
},
{
pagePath: "pages/ar/index",
text: "AR体验",
iconPath: "./assets/cart.png", // Placeholder
pagePath: "pages/courses/index",
text: "VB课程",
iconPath: "./assets/VR.png",
selectedIconPath: "./assets/VR_active.png"
},
{
pagePath: "pages/cart/cart",
text: "购物车",
iconPath: "./assets/cart.png",
selectedIconPath: "./assets/cart_active.png"
},
{

View File

@@ -1,8 +1,18 @@
page {
background-color: #f7f8fa;
--primary-cyan: #00f0ff;
--primary-green: #00b96b;
--primary-purple: #bd00ff;
--bg-dark: #050505;
--card-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.08);
--text-main: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.7);
background-color: var(--bg-dark);
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei',
sans-serif;
color: var(--text-main);
}
.container {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,6 +1,104 @@
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M49.4996 16.5C49.4996 7.38729 42.1123 0 32.9996 0C23.8869 0 16.4996 7.38729 16.4996 16.5H49.4996Z" fill="white"/>
<path d="M33 66C42.1127 66 49.5 58.6127 49.5 49.5H16.5C16.5 58.6127 23.8873 66 33 66Z" fill="white"/>
<path d="M66 33C66 42.1127 58.6127 49.5 49.5 49.5V16.5C58.6127 16.5 66 23.8873 66 33Z" fill="white"/>
<path d="M0 33C0 23.8873 7.3873 16.5 16.5 16.5V49.5C7.3873 49.5 0 42.1127 0 33Z" fill="white"/>
<?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.050000,-0.050000)"
fill="#FFFFFF" stroke="none">
<path d="M1836 3803 c-149 -150 -196 -223 -196 -304 0 -58 24 -46 85 42 95
137 365 284 404 220 17 -27 127 -410 120 -417 -3 -4 -43 21 -90 55 -108 78
-289 58 -349 -38 -27 -45 9 -46 101 -2 225 105 249 -11 66 -313 -41 -68 -99
-170 -127 -227 -47 -91 -58 -101 -102 -90 -78 19 -88 -3 -48 -95 21 -47 44
-125 52 -172 29 -184 162 -172 247 23 25 58 98 213 161 345 63 132 121 267
129 300 18 75 31 60 153 -178 208 -401 479 -647 755 -684 106 -14 223 32 223
87 0 64 -209 203 -307 205 -115 1 -389 222 -556 447 -182 243 -255 595 -117
559 49 -13 68 30 43 96 -28 71 -174 218 -217 218 -20 0 -86 18 -146 40 -60 22
-114 40 -120 39 -5 0 -79 -70 -164 -156z"/>
<path d="M5153 3827 c-24 -25 -13 -47 23 -47 98 0 112 -112 22 -178 -50 -37
-78 -44 -140 -34 -100 16 -109 -43 -18 -108 79 -56 76 -68 -29 -108 -115 -45
-191 -105 -191 -153 0 -45 18 -48 87 -17 59 27 142 -9 125 -54 -6 -15 -3 -50
7 -75 13 -35 9 -56 -16 -82 -31 -32 -29 -34 27 -28 118 11 61 -34 -136 -108
-121 -45 -234 -75 -288 -75 -104 0 -107 -30 -8 -87 l64 -38 177 83 c182 84
241 99 241 60 0 -53 -93 -181 -166 -229 -100 -67 -76 -108 43 -72 126 38 174
89 224 242 39 117 54 141 97 150 103 21 219 18 248 -6 59 -49 423 -22 430 32
6 37 -8 52 -70 76 -42 17 -98 41 -126 53 -27 13 -59 21 -70 18 -29 -8 -357
-61 -376 -62 -67 -1 7 94 95 123 145 48 141 75 -17 126 -35 11 -34 18 23 77
47 48 58 72 44 96 -22 40 30 93 114 116 83 22 78 60 -21 167 -70 76 -101 95
-155 96 -37 0 -89 13 -117 29 -53 30 -125 39 -147 17z m286 -115 c27 -33 -11
-144 -66 -189 -43 -36 -44 -1 -5 113 16 44 24 86 17 92 -6 7 -2 12 9 12 12 0
32 -13 45 -28z m-211 -378 c17 -27 10 -45 -33 -88 l-55 -55 -70 38 c-89 47
-88 54 15 106 95 47 113 47 143 -1z"/>
<path d="M1151 3392 c11 -60 8 -72 -21 -72 -19 0 -52 -22 -74 -50 -21 -27 -53
-50 -70 -50 -25 0 -26 5 -6 30 14 17 20 36 14 43 -7 7 -23 -14 -35 -45 -41
-103 -53 -118 -94 -118 -59 0 -75 -24 -27 -40 23 -7 43 -23 44 -36 6 -59 -8
-127 -31 -154 -41 -50 -62 -95 -30 -65 38 35 59 31 58 -10 0 -42 -96 -117
-118 -92 -109 123 -96 467 17 467 12 0 22 9 22 20 0 11 -12 20 -26 20 -14 0
-61 18 -105 40 -110 57 -138 41 -99 -53 17 -41 44 -147 59 -236 16 -88 43
-194 60 -235 43 -103 40 -114 -37 -146 -66 -28 -92 -63 -92 -125 0 -26 20 -29
128 -19 100 9 175 1 332 -37 281 -68 620 -39 620 53 0 44 -63 76 -112 57 -28
-10 -98 -19 -157 -19 l-106 0 -12 75 c-7 41 -13 130 -13 197 l0 123 85 31 c47
17 108 34 135 38 28 3 50 19 50 36 0 41 -124 79 -215 66 -85 -13 -115 59 -35
84 49 16 53 81 6 107 -19 11 -44 53 -55 94 -29 103 -79 120 -60 21z m9 -367
c0 -93 -110 -178 -146 -113 -17 31 -22 29 -46 -14 l-27 -48 0 54 c-1 34 12 57
35 65 21 6 63 38 94 71 75 78 90 76 90 -15z m0 -254 c0 -105 -24 -126 -91 -82
-74 48 -59 13 35 -83 106 -108 100 -113 -121 -83 -178 23 -183 26 -183 76 0
60 95 129 148 109 21 -8 37 2 48 31 14 38 111 116 149 120 8 0 15 -39 15 -88z"/>
<path d="M3842 3282 c-44 -28 -6 -62 70 -62 96 0 201 -48 218 -99 22 -69 -171
-359 -400 -601 -253 -267 -269 -338 -114 -505 71 -76 113 -69 184 30 34 47
107 147 163 223 309 424 418 676 349 808 -75 145 -365 272 -470 206z"/>
<path d="M5331 2779 c7 -12 75 -62 151 -110 84 -54 138 -103 138 -124 1 -68
198 -200 226 -150 55 98 13 173 -156 281 -125 79 -392 156 -359 103z"/>
<path d="M4240 2461 c0 -10 52 -59 116 -108 64 -49 147 -124 186 -166 170
-188 257 -247 361 -247 206 0 55 221 -243 355 -60 27 -173 80 -250 117 -148
73 -170 79 -170 49z"/>
<path d="M1145 2247 c-31 -13 -277 -299 -335 -392 -20 -31 -151 -222 -327
-475 -49 -71 -106 -148 -126 -170 -19 -23 -59 -86 -88 -140 -29 -55 -92 -151
-140 -213 -129 -165 -137 -196 -89 -356 83 -281 158 -274 309 29 63 127 149
268 192 315 42 47 87 109 99 137 21 49 34 53 186 65 90 7 223 18 295 25 118
12 130 9 131 -25 0 -20 7 -71 15 -114 14 -67 10 -77 -27 -87 -85 -22 67 -396
161 -396 l49 0 -1 240 c-1 340 -88 1041 -148 1198 -13 35 -17 104 -9 172 19
167 -33 233 -147 187z m44 -412 c16 -104 51 -542 43 -550 -4 -4 -82 -10 -175
-14 -92 -3 -187 -13 -212 -21 -67 -22 -57 16 43 178 48 78 94 164 102 192 19
61 155 280 174 280 8 0 19 -29 25 -65z"/>
<path d="M5212 1983 c-7 -11 15 -118 49 -237 161 -559 176 -476 -116 -627 -94
-49 -189 -113 -211 -143 -22 -30 -48 -50 -57 -44 -36 22 -14 79 65 173 266
315 280 517 46 634 -133 66 -203 42 -106 -37 65 -52 -40 -220 -319 -507 l-111
-114 34 -71 c19 -39 34 -83 34 -98 0 -60 78 -102 145 -76 80 30 80 29 52 -94
-15 -65 -17 -110 -6 -118 11 -8 37 -50 59 -94 82 -164 121 -122 113 120 -8
250 11 297 131 317 42 7 147 44 235 83 87 38 162 65 167 61 10 -10 79 -260
113 -411 14 -60 40 -145 58 -187 18 -43 33 -98 33 -122 0 -63 140 -310 201
-355 126 -93 150 -17 146 464 -5 576 -6 600 -27 600 -11 0 -20 -27 -20 -60 0
-71 -107 -415 -146 -468 -25 -34 -30 -29 -54 60 -14 54 -40 125 -57 158 -38
74 -122 328 -123 372 0 17 25 44 55 59 30 16 80 46 110 67 30 22 93 44 140 51
181 24 102 101 -155 150 -262 50 -273 59 -338 267 -54 174 -114 270 -140 227z
m-364 -559 c-75 -99 -310 -344 -331 -344 -9 0 71 103 178 228 180 211 282 288
153 116z m691 56 c102 -52 111 -69 52 -108 -75 -49 -103 -39 -128 45 -36 122
-37 121 76 63z"/>
<path d="M5560 1980 c0 -11 9 -20 19 -20 11 0 27 -29 37 -65 11 -46 36 -75 85
-98 131 -63 148 35 26 143 -66 58 -167 82 -167 40z"/>
<path d="M2490 1947 c-69 -16 -169 -24 -235 -18 -100 9 -115 6 -115 -24 1 -77
77 -125 201 -125 71 0 81 -5 69 -35 -8 -19 -19 -53 -25 -75 -23 -95 -197 -554
-265 -700 -99 -214 -106 -219 -298 -203 -170 15 -182 8 -182 -96 0 -89 28 -94
506 -84 446 9 534 23 534 85 0 66 -265 168 -337 129 -161 -86 -171 29 -20 242
20 29 37 71 37 93 0 44 72 216 166 394 39 75 63 150 63 200 l1 80 145 1 c165
1 212 26 158 85 -72 80 -205 97 -403 51z"/>
<path d="M3873 1627 c-7 -84 -13 -191 -13 -237 l0 -84 -135 -51 c-74 -28 -160
-57 -191 -64 -32 -7 -53 -23 -49 -37 14 -42 167 -39 267 5 51 22 96 41 101 41
4 0 7 -60 7 -132 l0 -133 -65 15 c-148 35 -275 -7 -275 -92 0 -32 5 -33 61 -4
62 31 169 24 244 -16 37 -21 48 -149 22 -277 -20 -100 -244 -43 -288 73 -11
28 -25 47 -30 42 -40 -41 318 -436 394 -436 65 0 77 88 77 569 l0 467 63 22
c35 12 89 22 120 22 94 0 77 73 -23 100 -99 27 -160 90 -160 164 0 214 -109
251 -127 43z"/>
<path d="M3063 1349 c-22 -28 -53 -94 -69 -145 -85 -278 -83 -254 -38 -350 77
-164 174 -184 245 -51 23 41 24 41 80 -21 95 -104 146 -23 147 238 2 292 -70
418 -194 341 -33 -21 -43 -20 -58 7 -26 46 -67 39 -113 -19z m237 -129 c0 -59
-8 -80 -31 -80 -17 0 -58 -14 -90 -31 -82 -42 -81 8 3 113 81 103 118 102 118
-2z m0 -167 c0 -70 -82 -137 -122 -101 -53 48 -47 81 17 105 78 28 105 27 105
-4z m-140 -132 c16 -32 13 -43 -20 -61 -36 -19 -40 -15 -40 39 0 69 29 80 60
22z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -1,51 +0,0 @@
import { View, Text, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getARServiceDetail } from '../../api'
import './detail.scss'
export default function ARDetail() {
const [detail, setDetail] = useState<any>(null)
const [loading, setLoading] = useState(true)
useLoad((options) => {
if (options.id) fetchDetail(options.id)
})
const fetchDetail = async (id: string) => {
try {
const res: any = await getARServiceDetail(Number(id))
setDetail(res)
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleLaunch = () => {
Taro.showModal({
title: '提示',
content: '请使用摄像头扫描空间以启动 AR 体验 (演示模式)',
showCancel: false
})
}
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
return (
<View className='page-container'>
<Text className='title'>{detail.title}</Text>
<Text className='desc'>{detail.description}</Text>
<View className='ar-placeholder'>
<Text className='icon'>📷</Text>
<Text className='text'>AR </Text>
</View>
<Button className='btn-launch' onClick={handleLaunch}></Button>
</View>
)
}

View File

@@ -1,8 +1,214 @@
.page-container {
min-height: 100vh;
background-color: #f7f8fa;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #050505;
color: #fff;
padding-bottom: 120px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.empty-state {
height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-text {
font-size: 28px;
color: #666;
}
}
.cart-list {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.cart-item {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
backdrop-filter: blur(10px);
.checkbox-area {
padding: 10px;
margin-right: 10px;
.checkbox {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #666;
display: flex;
align-items: center;
justify-content: center;
&.checked {
border-color: #00b96b;
background: rgba(0, 185, 107, 0.2);
color: #00b96b;
}
}
}
.item-img {
width: 160px;
height: 160px;
border-radius: 12px;
margin-right: 20px;
background: #000;
object-fit: cover;
}
.item-info {
flex: 1;
height: 160px;
display: flex;
flex-direction: column;
justify-content: space-between;
.item-name {
font-size: 30px;
font-weight: bold;
color: #fff;
margin-bottom: 8px;
}
.item-desc {
font-size: 24px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
.price {
font-size: 32px;
color: #00b96b;
font-weight: bold;
}
.quantity-control {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 4px;
.btn-qty {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #fff;
&:active { opacity: 0.7; }
}
.qty-num {
width: 60px;
text-align: center;
font-size: 28px;
font-weight: bold;
}
}
}
}
.btn-delete {
padding: 10px;
margin-left: 10px;
color: #ff4d4f;
font-size: 32px;
}
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 110px;
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
z-index: 100;
.left-section {
display: flex;
align-items: center;
.select-all-btn {
display: flex;
align-items: center;
margin-right: 30px;
.checkbox {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #666;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
&.checked {
border-color: #00b96b;
background: rgba(0, 185, 107, 0.2);
color: #00b96b;
}
}
.label { font-size: 28px; color: #fff; }
}
.total-info {
.label { font-size: 24px; color: #888; margin-right: 10px; }
.price { font-size: 40px; color: #00b96b; font-weight: bold; }
}
}
.btn-checkout {
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
color: #000;
border-radius: 40px;
padding: 0 50px;
height: 80px;
line-height: 80px;
font-size: 32px;
font-weight: bold;
border: none;
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
&:active { transform: scale(0.98); }
&.disabled {
background: #333;
color: #666;
box-shadow: none;
}
}
}
.empty { color: #999; font-size: 16px; }

View File

@@ -1,12 +1,145 @@
import { View, Text } from '@tarojs/components'
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useState, useMemo } from 'react'
import { getCart, updateQuantity, removeItem, toggleSelect, toggleSelectAll, CartItem } from '../../utils/cart'
import './cart.scss'
export default function Cart() {
const [cartItems, setCartItems] = useState<CartItem[]>([])
useDidShow(() => {
refreshCart()
})
const refreshCart = () => {
setCartItems(getCart())
}
const handleUpdateQuantity = (id: number, delta: number) => {
const item = cartItems.find(i => i.id === id)
if (!item) return
const newQty = item.quantity + delta
if (newQty < 1) return
const newCart = updateQuantity(id, newQty)
setCartItems(newCart)
}
const handleRemove = (id: number) => {
Taro.showModal({
title: '提示',
content: '确定要删除该商品吗?',
success: (res) => {
if (res.confirm) {
const newCart = removeItem(id)
setCartItems(newCart)
}
}
})
}
const handleToggle = (id: number) => {
const newCart = toggleSelect(id)
setCartItems(newCart)
}
const isAllSelected = useMemo(() => {
return cartItems.length > 0 && cartItems.every(i => i.selected)
}, [cartItems])
const handleToggleAll = () => {
const newCart = toggleSelectAll(!isAllSelected)
setCartItems(newCart)
}
const selectedCount = useMemo(() => {
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.quantity, 0)
}, [cartItems])
const totalPrice = useMemo(() => {
return cartItems.filter(i => i.selected).reduce((sum, i) => sum + i.price * i.quantity, 0)
}, [cartItems])
const handleCheckout = () => {
if (selectedCount === 0) {
Taro.showToast({ title: '请选择商品', icon: 'none' })
return
}
Taro.navigateTo({
url: '/pages/order/checkout?from=cart'
})
}
const goShopping = () => {
Taro.switchTab({ url: '/pages/index/index' })
}
return (
<View className='page-container'>
<View className='empty'>
<Text>线</Text>
</View>
{cartItems.length === 0 ? (
<View className='empty-state'>
<Text className='empty-icon'>🛒</Text>
<Text className='empty-text'></Text>
<Button onClick={goShopping} style={{marginTop: 20, background: '#00b96b', color: '#fff'}}></Button>
</View>
) : (
<ScrollView scrollY className='cart-list'>
{cartItems.map(item => (
<View key={item.id} className='cart-item'>
<View className='checkbox-area' onClick={() => handleToggle(item.id)}>
<View className={`checkbox ${item.selected ? 'checked' : ''}`}>
{item.selected && <Text></Text>}
</View>
</View>
<Image src={item.image} className='item-img' mode='aspectFill' />
<View className='item-info'>
<View>
<Text className='item-name'>{item.name}</Text>
{/* <Text className='item-desc'>{item.description}</Text> */}
</View>
<View className='price-row'>
<Text className='price'>¥{item.price}</Text>
<View className='quantity-control'>
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, -1)}></View>
<Text className='qty-num'>{item.quantity}</Text>
<View className='btn-qty' onClick={() => handleUpdateQuantity(item.id, 1)}>+</View>
</View>
</View>
</View>
<View className='btn-delete' onClick={() => handleRemove(item.id)}>×</View>
</View>
))}
</ScrollView>
)}
{cartItems.length > 0 && (
<View className='bottom-bar'>
<View className='left-section'>
<View className='select-all-btn' onClick={handleToggleAll}>
<View className={`checkbox ${isAllSelected ? 'checked' : ''}`}>
{isAllSelected && <Text></Text>}
</View>
<Text className='label'></Text>
</View>
<View className='total-info'>
<Text className='label'>:</Text>
<Text className='price'>¥{totalPrice}</Text>
</View>
</View>
<Button
className={`btn-checkout ${selectedCount === 0 ? 'disabled' : ''}`}
onClick={handleCheckout}
>
({selectedCount})
</Button>
</View>
)}
</View>
)
}

View File

@@ -13,6 +13,28 @@
display: block;
}
.meta-info {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 30px;
align-items: center;
.tag {
background: rgba(0, 240, 255, 0.2);
color: #00f0ff;
padding: 6px 16px;
border-radius: 4px;
font-size: 24px;
border: 1px solid #00f0ff;
}
.info {
color: #888;
font-size: 26px;
}
}
.desc {
color: #aaa;
font-size: 28px;
@@ -20,7 +42,7 @@
display: block;
}
.ar-placeholder {
.course-placeholder {
width: 100%;
height: 500px;
background: #111;

View File

@@ -0,0 +1,70 @@
import { View, Text, Button, Image } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getVBCourseDetail } from '../../api'
import './detail.scss'
export default function CourseDetail() {
const [detail, setDetail] = useState<any>(null)
const [loading, setLoading] = useState(true)
useLoad((options) => {
if (options.id) fetchDetail(options.id)
})
const typeMap: Record<string, string> = {
software: '软件课程',
hardware: '硬件课程',
incubation: '产品商业孵化'
}
const fetchDetail = async (id: string) => {
try {
const res: any = await getVBCourseDetail(Number(id))
setDetail(res)
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleLaunch = () => {
Taro.showToast({
title: '课程内容准备中',
icon: 'none'
})
}
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
if (!detail) return <View className='page-container'><Text style={{color:'#fff'}}>Not Found</Text></View>
return (
<View className='page-container'>
<Text className='title'>{detail.title}</Text>
<View className='meta-info'>
<Text className='tag'>{typeMap[detail.course_type] || '软件课程'}</Text>
<Text className='info'>: {detail.instructor}</Text>
<Text className='info'>: {detail.duration}</Text>
<Text className='info'>: {detail.lesson_count}</Text>
</View>
<Text className='desc'>{detail.description}</Text>
<View className='course-placeholder'>
{detail.display_detail_image ? (
<Image src={detail.display_detail_image} style={{ width: '100%', height: '100%', borderRadius: '16px' }} mode='widthFix' />
) : (
<>
<Text className='icon'>📚</Text>
<Text className='text'></Text>
</>
)}
</View>
<Button className='btn-launch' onClick={handleLaunch}></Button>
</View>
)
}

View File

@@ -68,6 +68,33 @@
font-size: 80px;
font-weight: bold;
}
.tag-container {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 10px;
}
.type-tag {
background: rgba(0, 240, 255, 0.2);
border: 1px solid #00f0ff;
padding: 4px 12px;
border-radius: 4px;
.type-text {
color: #00f0ff;
font-size: 20px;
}
&.special {
background: rgba(255, 87, 34, 0.2);
border: 1px solid #ff5722;
.type-text {
color: #ff5722;
}
}
}
}
.content {
@@ -80,6 +107,16 @@
display: block;
}
.info-row {
display: flex;
gap: 20px;
margin-bottom: 10px;
.info-text {
color: #aaa;
font-size: 24px;
}
}
.item-desc {
color: #888;
font-size: 26px;

View File

@@ -1,21 +1,21 @@
import { View, Text, Image, Button } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getARServices } from '../../api'
import { getVBCourses } from '../../api'
import './index.scss'
export default function ARIndex() {
const [arList, setArList] = useState<any[]>([])
export default function CourseIndex() {
const [courseList, setCourseList] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useLoad(() => {
fetchAR()
fetchCourses()
})
const fetchAR = async () => {
const fetchCourses = async () => {
try {
const res: any = await getARServices()
setArList(res.results || res)
const res: any = await getVBCourses()
setCourseList(res.results || res)
} catch (err) {
console.error(err)
Taro.showToast({ title: '加载失败', icon: 'none' })
@@ -25,7 +25,7 @@ export default function ARIndex() {
}
const goDetail = (id: number) => {
Taro.navigateTo({ url: `/pages/ar/detail?id=${id}` })
Taro.navigateTo({ url: `/pages/courses/detail?id=${id}` })
}
if (loading) return <View className='page-container'><Text style={{color:'#fff'}}>Loading...</Text></View>
@@ -35,29 +35,29 @@ export default function ARIndex() {
<View className='bg-decoration' />
<View className='header'>
<Text className='title'>AR <Text className='highlight'>UNIVERSE</Text></Text>
<Text className='desc'></Text>
<Text className='title'>VB <Text className='highlight'>COURSES</Text></Text>
<Text className='desc'> VB </Text>
</View>
<View className='ar-grid'>
{arList.length === 0 ? (
{courseList.length === 0 ? (
<View style={{ width: '100%', textAlign: 'center', color: '#666', marginTop: 50 }}>
<Text> AR </Text>
<Text> VB </Text>
</View>
) : (
arList.map((item) => (
courseList.map((item) => (
<View key={item.id} className='ar-card' onClick={() => goDetail(item.id)}>
<View className='cover-box'>
{item.cover_image_url ? (
<Image src={item.cover_image_url} className='cover-img' mode='aspectFill' />
) : (
<Text className='placeholder-icon'>AR</Text>
<Text className='placeholder-icon'>VB</Text>
)}
</View>
<View className='content'>
<Text className='item-title'>{item.title}</Text>
<Text className='item-desc'>{item.description}</Text>
<Button className='btn-start'></Button>
<Button className='btn-start'></Button>
</View>
</View>
))

View File

@@ -1,8 +1,10 @@
.page-container {
height: 100vh;
background-color: #000;
min-height: 100vh;
background-color: #050505;
color: #fff;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
overflow-x: hidden;
}
.loading-screen, .error-screen {
@@ -10,147 +12,259 @@
display: flex;
align-items: center;
justify-content: center;
color: #666;
color: #00f0ff;
background: #000;
font-size: 28px;
letter-spacing: 2px;
}
.content {
height: 100vh;
background: #000;
position: relative;
z-index: 1;
padding-bottom: 200px; // Ensure scroll space for bottom bar
}
.glass-panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
// Animations
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
100% { transform: translateY(0px); }
}
@keyframes pulse-glow {
0% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
50% { box-shadow: 0 0 25px rgba(0, 185, 107, 0.8), 0 0 10px rgba(0, 240, 255, 0.4); }
100% { box-shadow: 0 0 10px rgba(0, 185, 107, 0.4); }
}
@keyframes scanline {
0% { top: -10%; opacity: 0; }
50% { opacity: 1; }
100% { top: 110%; opacity: 0; }
}
// Hero Section
.hero-section {
position: relative;
margin-bottom: 20px;
margin-bottom: 40px;
animation: fadeInUp 0.8s ease-out;
.image-container {
width: 100%;
min-height: 600px;
background: radial-gradient(circle at center, #1a1a1a, #000);
min-height: 600px; // Slightly reduced to fit better
background: radial-gradient(circle at center, rgba(0, 240, 255, 0.05) 0%, transparent 70%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
// Scanline effect
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(to right, transparent, rgba(0, 240, 255, 0.5), transparent);
animation: scanline 3s linear infinite;
z-index: 0;
}
.hero-img {
width: 100%;
width: 75%;
height: auto;
display: block;
filter: drop-shadow(0 0 40px rgba(0, 240, 255, 0.2));
animation: float 6s ease-in-out infinite;
z-index: 1;
}
.placeholder-box {
.icon-bolt { font-size: 100px; }
}
.hero-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60%;
background: linear-gradient(to top, #000 10%, transparent);
.icon-bolt { font-size: 150px; color: #00b96b; text-shadow: 0 0 30px rgba(0, 185, 107, 0.5); }
}
}
.hero-content {
padding: 0 30px;
margin-top: -100px; // Pull up over image
padding: 0 40px;
margin-top: -40px;
position: relative;
z-index: 2;
.hero-title {
font-size: 48px;
font-size: 60px;
font-weight: 900;
color: #fff;
display: block;
margin-bottom: 15px;
text-shadow: 0 0 20px rgba(0,0,0,0.8);
margin-bottom: 24px;
line-height: 1.1;
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
letter-spacing: 1px;
}
.hero-desc {
font-size: 28px;
color: #ccc;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
display: block;
margin-bottom: 25px;
text-shadow: 0 0 10px rgba(0,0,0,0.8);
margin-bottom: 32px;
font-weight: 300;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
gap: 16px;
.tag {
padding: 8px 20px;
border-radius: 30px;
padding: 10px 28px;
border-radius: 4px; // Techy sharp corners
font-size: 24px;
font-weight: 600;
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
&.cyan { background: rgba(0, 240, 255, 0.15); color: #00f0ff; border: 1px solid rgba(0, 240, 255, 0.3); }
&.blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
&.purple { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
// Tech border effect
&::before {
content: '';
position: absolute;
top: 0; left: 0; width: 4px; height: 100%;
}
&.cyan {
color: #00f0ff;
background: rgba(0, 240, 255, 0.08);
border: 1px solid rgba(0, 240, 255, 0.3);
&::before { background: #00f0ff; }
}
&.blue {
color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.3);
&::before { background: #3b82f6; }
}
&.purple {
color: #a855f7;
background: rgba(168, 85, 247, 0.08);
border: 1px solid rgba(168, 85, 247, 0.3);
&::before { background: #a855f7; }
}
}
}
}
}
// Stats Card (HUD Style)
.stats-card {
margin: 0 30px 40px;
border-radius: 24px;
padding: 30px;
display: flex;
align-items: center;
justify-content: space-around;
margin: 40px 40px 60px;
padding: 30px !important;
background: rgba(20, 20, 20, 0.6) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 12px;
position: relative;
backdrop-filter: blur(10px) !important;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
.stat-item {
text-align: center;
.stat-label { font-size: 24px; color: #888; display: block; margin-bottom: 10px; }
.stat-value { font-size: 36px; font-weight: bold; color: #fff; }
.price { color: #00b96b; text-shadow: 0 0 10px rgba(0, 185, 107, 0.3); }
.low-stock { color: #ff4d4f; }
// Corner accents
&::before {
content: '';
position: absolute;
top: -1px; left: -1px;
width: 20px; height: 20px;
border-top: 2px solid #00b96b;
border-left: 2px solid #00b96b;
border-top-left-radius: 12px;
}
&::after {
content: '';
position: absolute;
bottom: -1px; right: -1px;
width: 20px; height: 20px;
border-bottom: 2px solid #00b96b;
border-right: 2px solid #00b96b;
border-bottom-right-radius: 12px;
}
.divider { width: 1px; height: 60px; background: rgba(255,255,255,0.1); }
.label-row {
display: flex;
width: 100%;
margin-bottom: 12px;
.label { font-size: 24px; color: #666; flex: 1; text-transform: uppercase; letter-spacing: 1px; }
}
.value-row {
display: flex;
width: 100%;
align-items: baseline;
.price-box {
flex: 1;
display: flex;
align-items: baseline;
.symbol { font-size: 32px; color: #00b96b; font-weight: bold; margin-right: 4px; }
.price {
font-size: 72px;
color: #00b96b;
font-weight: bold;
text-shadow: 0 0 25px rgba(0, 185, 107, 0.4);
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; // Ensure clean number font
}
}
.stock-box {
.stock { font-size: 36px; color: #fff; font-weight: bold; }
.unit { font-size: 24px; color: #666; margin-left: 6px; }
}
}
}
// Features Section
.features-section {
padding: 0 30px;
padding: 0 40px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 40px;
gap: 40px;
margin-bottom: 60px;
.feature-card {
padding: 30px;
border-radius: 20px;
display: flex;
align-items: flex-start;
flex-direction: row; // Change to row for better list layout
align-items: center;
text-align: left;
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
border-radius: 16px;
padding: 30px;
animation: fadeInUp 0.8s ease-out;
// Stagger animations manually or via JS (here simplified)
.feature-icon-box {
width: 80px;
height: 80px;
margin-right: 25px;
width: 100px;
height: 100px;
margin-right: 30px;
margin-bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
border-radius: 16px;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
.f-icon { font-size: 40px; color: #00f0ff; }
.f-icon-img { width: 50px; height: 50px; }
.f-icon { font-size: 50px; color: #00b96b; }
.f-icon-img { width: 60px; height: 60px; object-fit: contain; }
}
.feature-text {
flex: 1;
.f-title { font-size: 30px; font-weight: bold; color: #fff; margin-bottom: 10px; display: block; }
.f-desc { font-size: 24px; color: #aaa; line-height: 1.5; }
.f-title { font-size: 32px; font-weight: bold; color: #fff; margin-bottom: 10px; display: block; }
.f-desc { font-size: 24px; color: #888; line-height: 1.5; }
}
}
}
@@ -158,66 +272,94 @@
.detail-image-section {
width: 100%;
margin-bottom: 40px;
.long-detail-img { width: 100%; display: block; }
position: relative;
// Decorative line top
&::before {
content: '';
display: block;
width: 100px;
height: 4px;
background: #333;
margin: 0 auto 40px;
border-radius: 2px;
}
.long-detail-img { width: 100%; height: auto; display: block; }
}
.footer-spacer { height: 160px; }
.footer-spacer { height: 200px; }
// Bottom Bar
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20px 30px;
bottom: 40px;
left: 30px;
right: 30px;
height: 110px;
z-index: 100;
border-top-left-radius: 30px;
border-top-right-radius: 30px;
background: rgba(20, 20, 20, 0.95); // Darker for contrast
border-radius: 55px; // Fully rounded capsule
background: rgba(20, 20, 20, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
.action-row {
width: 100%;
height: 100%;
display: flex;
align-items: center;
gap: 20px;
height: 100px;
.cart-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 20px;
.icon { font-size: 40px; margin-bottom: 5px; }
.label { font-size: 20px; color: #888; }
}
.btn-add-cart, .btn-buy-now {
.btn-add-cart {
flex: 1;
height: 80px;
line-height: 80px;
border-radius: 40px;
font-size: 28px;
height: 100%;
border-radius: 45px 0 0 45px;
font-size: 30px;
font-weight: bold;
border: none;
margin: 0;
&::after { border: none; }
}
.btn-add-cart {
background: rgba(255, 255, 255, 0.1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
&:active { background: rgba(255, 255, 255, 0.2); }
}
.btn-buy-now {
background: linear-gradient(90deg, #00b96b, #00f0ff);
color: #000;
box-shadow: 0 5px 20px rgba(0, 185, 107, 0.3);
flex: 1;
height: 100%;
border-radius: 0 45px 45px 0;
font-size: 30px;
font-weight: 800;
border: none;
margin: 0;
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
color: #000; // Black text for high contrast on neon
display: flex;
align-items: center;
justify-content: center;
animation: pulse-glow 3s infinite;
transition: all 0.2s ease;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
.cart-icon {
font-size: 36px;
margin-right: 12px;
}
}
}
}
.safe-area-bottom {
padding-bottom: calc(20px + constant(safe-area-inset-bottom));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
padding-bottom: 0;
}

View File

@@ -2,6 +2,8 @@ import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
import Taro, { useRouter, useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { getConfigDetail } from '../../api'
import ParticleBackground from '../../components/ParticleBackground'
import { addToCart } from '../../utils/cart'
import './detail.scss'
export default function Detail() {
@@ -26,6 +28,11 @@ export default function Detail() {
}
}
const handleAddToCart = () => {
if (!product) return
addToCart(product)
}
const buyNow = () => {
if (!product) return
Taro.navigateTo({
@@ -38,12 +45,13 @@ export default function Detail() {
return (
<View className='page-container'>
<ParticleBackground />
<ScrollView scrollY className='content'>
{/* Hero Section */}
<View className='hero-section'>
<View className='image-container'>
{product.detail_image_url || product.static_image_url ? (
<Image src={product.detail_image_url || product.static_image_url} mode='widthFix' className='hero-img' />
{product.static_image_url ? (
<Image src={product.static_image_url} mode='widthFix' className='hero-img' />
) : (
<View className='placeholder-box'>
<Text className='icon-bolt'></Text>
@@ -65,44 +73,84 @@ export default function Detail() {
</View>
{/* Stats Section */}
<View className='stats-card glass-panel'>
<View className='stat-item'>
<Text className='stat-label'></Text>
<Text className='stat-value price'>¥{product.price}</Text>
<View className='stats-card'>
<View className='label-row'>
<Text className='label'></Text>
<Text className='label' style={{textAlign: 'right'}}></Text>
</View>
<View className='divider' />
<View className='stat-item'>
<Text className='stat-label'></Text>
<Text className={`stat-value ${product.stock < 10 ? 'low-stock' : ''}`}>{product.stock}</Text>
<View className='value-row'>
<View className='price-box'>
<Text className='symbol'>¥</Text>
<Text className='price'>{product.price}</Text>
</View>
<View className='stock-box'>
<Text className='stock'>{product.stock}</Text>
<Text className='unit'></Text>
</View>
</View>
</View>
{/* Features Section */}
<View className='features-section'>
{product.features && product.features.length > 0 ? (
product.features.map((f, idx) => (
<View key={idx} className='feature-card glass-panel'>
<View className='feature-icon-box'>
{f.icon_url ? <Image src={f.icon_url} className='f-icon-img' /> : <Text className='f-icon'></Text>}
product.features.map((f, idx) => {
let iconContent
if (f.display_icon) {
iconContent = <Image src={f.display_icon} className='f-icon-img' />
} else if (f.icon_url) {
iconContent = <Image src={f.icon_url} className='f-icon-img' />
} else {
let iconChar = '⭐'
let iconColor = '#00b96b'
switch(f.icon_name) {
case 'SafetyCertificate': iconChar = '🛡'; break;
case 'Eye': iconChar = '👁'; iconColor = '#3b82f6'; break;
case 'Thunderbolt': iconChar = '⚡'; iconColor = '#faad14'; break;
default: break;
}
iconContent = <Text className='f-icon' style={{color: iconColor}}>{iconChar}</Text>
}
return (
<View key={idx} className='feature-card'>
<View className='feature-icon-box'>
{iconContent}
</View>
<View className='feature-text'>
<Text className='f-title'>{f.title}</Text>
<Text className='f-desc'>{f.description}</Text>
</View>
</View>
<View className='feature-text'>
<Text className='f-title'>{f.title}</Text>
<Text className='f-desc'>{f.description}</Text>
</View>
</View>
))
)
})
) : (
<View className='feature-card glass-panel'>
<Text className='f-title'></Text>
<Text className='f-desc'>{product.chip_type} </Text>
</View>
<>
<View className='feature-card'>
<View className='feature-icon-box'>
<Text className='f-icon'>🛡</Text>
</View>
<View className='feature-text'>
<Text className='f-title'></Text>
<Text className='f-desc'></Text>
</View>
</View>
<View className='feature-card'>
<View className='feature-icon-box'>
<Text className='f-icon' style={{color: '#3b82f6'}}>👁</Text>
</View>
<View className='feature-text'>
<Text className='f-title' style={{color: '#3b82f6'}}></Text>
<Text className='f-desc'> 4K AI </Text>
</View>
</View>
</>
)}
</View>
{/* Detail Image */}
{product.detail_image_url && (
{(product.display_detail_image || product.detail_image_url) && (
<View className='detail-image-section'>
<Image src={product.detail_image_url} mode='widthFix' className='long-detail-img' />
<Image src={product.display_detail_image || product.detail_image_url} mode='widthFix' className='long-detail-img' />
</View>
)}
@@ -110,14 +158,15 @@ export default function Detail() {
</ScrollView>
{/* Bottom Bar */}
<View className='bottom-bar glass-panel safe-area-bottom'>
<View className='bottom-bar'>
<View className='action-row'>
<View className='cart-icon-btn' onClick={() => Taro.switchTab({ url: '/pages/cart/cart' })}>
<Text className='icon'>🛒</Text>
<Text className='label'></Text>
</View>
<Button className='btn-add-cart' onClick={() => Taro.showToast({title: '加入购物车', icon:'none'})}></Button>
<Button className='btn-buy-now' onClick={buyNow}></Button>
<Button className='btn-add-cart' onClick={handleAddToCart}>
<Text></Text>
</Button>
<Button className='btn-buy-now' onClick={buyNow}>
<Text className='cart-icon'>🛒</Text>
<Text></Text>
</Button>
</View>
</View>
</View>

View File

@@ -1,52 +1,79 @@
.page-container {
height: 100vh;
background-color: #000;
color: #fff;
background-color: var(--bg-dark);
color: var(--text-main);
overflow: hidden;
position: relative;
// Ambient Light 1 (Cyan)
&::before {
content: '';
position: absolute;
top: -10%;
left: -10%;
width: 60%;
height: 40%;
background: radial-gradient(circle, rgba(0, 240, 255, 0.15) 0%, transparent 70%);
filter: blur(80px);
z-index: 0;
pointer-events: none;
}
// Ambient Light 2 (Green/Purple mix)
&::after {
content: '';
position: absolute;
bottom: 10%;
right: -10%;
width: 50%;
height: 40%;
background: radial-gradient(circle, rgba(189, 0, 255, 0.1) 0%, transparent 70%);
filter: blur(80px);
z-index: 0;
pointer-events: none;
}
}
.content-scroll {
height: 100vh;
position: relative;
z-index: 1;
// Ensure no padding here
}
.scroll-inner {
// Container for scroll content
width: 100%;
}
.header {
text-align: center;
padding: 60px 20px 40px;
padding: 80px 24px 60px; // 增加头部留白
position: relative;
.logo-box {
margin-bottom: 30px;
margin-bottom: 40px;
display: flex;
flex-direction: column;
align-items: center;
.logo-img {
width: 120px;
height: 120px;
margin-bottom: 15px;
filter: drop-shadow(0 0 15px rgba(0, 240, 255, 0.4));
width: 140px;
height: 140px;
margin-bottom: 20px;
filter: drop-shadow(0 0 25px rgba(0, 240, 255, 0.5));
animation: float 6s ease-in-out infinite;
}
.logo-text {
font-size: 40px;
font-size: 48px;
font-weight: 900;
color: #fff;
letter-spacing: 6px;
text-shadow: 0 0 20px rgba(0, 240, 255, 0.6);
letter-spacing: 8px;
text-shadow: 0 0 30px rgba(0, 240, 255, 0.7);
}
}
.title-container {
margin-bottom: 25px;
margin-bottom: 30px;
display: flex;
justify-content: center;
align-items: center;
@@ -54,26 +81,27 @@
}
.title-text {
font-size: 36px;
font-weight: bold;
color: #00f0ff;
text-shadow: 0 0 15px rgba(0, 240, 255, 0.5);
font-size: 40px;
font-weight: 800;
color: var(--primary-cyan);
text-shadow: 0 0 20px rgba(0, 240, 255, 0.4);
}
.cursor {
font-size: 36px;
font-size: 40px;
color: #fff;
margin-left: 8px;
animation: blink 1s infinite;
}
.subtitle {
color: #aaa;
font-size: 26px;
line-height: 1.6;
color: var(--text-secondary);
font-size: 28px;
line-height: 1.8; // 增加行高
display: block;
padding: 0 40px;
font-weight: 300;
font-weight: 400;
letter-spacing: 1px;
}
}
@@ -82,40 +110,54 @@
50% { opacity: 0; }
}
.status-box {
padding: 100px 0;
text-align: center;
.loading-text { color: #00f0ff; font-size: 28px; }
.error-text { color: #ff4d4f; font-size: 28px; margin-bottom: 20px; display: block;}
.btn-retry { background: rgba(255,255,255,0.1); color: #fff; font-size: 24px; padding: 10px 40px; display: inline-block;}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.product-grid {
padding: 0 30px;
padding: 0 32px;
display: flex;
flex-direction: column;
gap: 40px;
gap: 48px; // 增加卡片间距
}
// 玻璃态卡片升级版
.card {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 32px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.4),
inset 0 0 0 1px rgba(255, 255, 255, 0.05); // 内描边增强质感
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
// 高光反射效果
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
opacity: 0.5;
}
&:active {
transform: scale(0.98);
border-color: #00b96b;
box-shadow: 0 0 30px rgba(0, 185, 107, 0.2);
transform: scale(0.96);
box-shadow:
0 10px 20px rgba(0, 0, 0, 0.4),
0 0 30px rgba(0, 240, 255, 0.1); // 按压发光
border-color: rgba(0, 240, 255, 0.3);
}
&-cover {
height: 360px;
height: 400px; // 加大图片区域
background: #111;
position: relative;
overflow: hidden;
@@ -123,7 +165,8 @@
.card-img {
width: 100%;
height: 100%;
transition: transform 0.5s ease;
object-fit: cover;
transition: transform 0.6s ease;
}
.placeholder-img {
@@ -132,8 +175,40 @@
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #222, #111);
.icon-rocket { font-size: 100px; }
background: radial-gradient(circle at center, #1a1a1a, #050505);
.radar-scan {
width: 100px;
height: 100px;
border: 2px solid rgba(0, 240, 255, 0.3);
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: var(--primary-cyan);
border-radius: 50%;
box-shadow: 0 0 10px var(--primary-cyan);
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent 0%, transparent 60%, rgba(0, 240, 255, 0.4) 100%);
animation: radar-spin 2s linear infinite;
}
}
}
.card-overlay {
@@ -141,44 +216,44 @@
bottom: 0;
left: 0;
width: 100%;
height: 50%;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
height: 60%;
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
}
}
&-body {
padding: 30px;
padding: 40px 32px;
}
&-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
margin-bottom: 20px;
.card-title {
font-size: 36px;
font-weight: bold;
font-size: 40px; // 加大标题
font-weight: 700;
color: #fff;
flex: 1;
margin-right: 20px;
line-height: 1.3;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
line-height: 1.2;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
.price {
font-size: 36px;
color: #00b96b;
font-weight: 900;
text-shadow: 0 0 15px rgba(0, 185, 107, 0.3);
color: var(--primary-cyan); // 统一用青色或根据产品类型变化
font-weight: 800;
text-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
}
}
&-desc {
font-size: 26px;
color: #ccc;
line-height: 1.5;
margin-bottom: 25px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 32px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -188,52 +263,126 @@
.tags {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 30px;
gap: 16px;
margin-bottom: 40px;
.tag {
padding: 8px 18px;
border-radius: 12px;
padding: 10px 24px;
border-radius: 16px;
font-size: 22px;
font-weight: 500;
letter-spacing: 0.5px;
&.cyan {
color: #00f0ff;
background: rgba(0, 240, 255, 0.1);
border: 1px solid rgba(0, 240, 255, 0.3);
color: var(--primary-cyan);
background: rgba(0, 240, 255, 0.08);
border: 1px solid rgba(0, 240, 255, 0.2);
}
&.blue {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
}
&.purple {
color: #a855f7;
background: rgba(168, 85, 247, 0.1);
border: 1px solid rgba(168, 85, 247, 0.3);
color: var(--primary-purple);
background: rgba(189, 0, 255, 0.08);
border: 1px solid rgba(189, 0, 255, 0.2);
}
}
}
&-footer {
.btn-buy {
background: linear-gradient(90deg, #00b96b, #00f0ff);
background: linear-gradient(90deg, var(--primary-green), var(--primary-cyan));
color: #000;
font-weight: bold;
font-weight: 800;
font-size: 30px;
border-radius: 50px;
border-radius: 60px; // 更圆润
border: none;
height: 80px;
line-height: 80px;
box-shadow: 0 5px 20px rgba(0, 185, 107, 0.3);
height: 90px;
line-height: 90px;
box-shadow: 0 10px 30px rgba(0, 185, 107, 0.25);
position: relative;
overflow: hidden;
// 流光效果
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 3s infinite;
}
&:active {
opacity: 0.9;
transform: scale(0.98);
box-shadow: 0 5px 15px rgba(0, 185, 107, 0.2);
}
}
}
}
.footer-spacer {
height: 100px;
@keyframes shimmer {
0% { left: -100%; }
20% { left: 100%; }
100% { left: 100%; }
}
@keyframes radar-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.footer-spacer {
height: 120px;
}
// 骨架屏样式
.skeleton-wrapper {
padding: 0 32px;
display: flex;
flex-direction: column;
gap: 48px;
}
.skeleton-card {
height: 700px;
background: rgba(255, 255, 255, 0.02);
border-radius: 32px;
border: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
animation: skeleton-loading 1.5s infinite;
}
}
@keyframes skeleton-loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
// 列表入场动画
.fade-in-up {
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
transform: translateY(40px);
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -65,8 +65,10 @@ export default function Index() {
</View>
{loading ? (
<View className='status-box'>
<Text className='loading-text'>...</Text>
<View className='skeleton-wrapper'>
{[1, 2, 3].map(i => (
<View key={i} className='skeleton-card' />
))}
</View>
) : error ? (
<View className='status-box'>
@@ -75,14 +77,19 @@ export default function Index() {
</View>
) : (
<View className='product-grid'>
{products.map((item) => (
<View key={item.id} className='card' onClick={() => goToDetail(item.id)}>
{products.map((item, index) => (
<View
key={item.id}
className='card fade-in-up'
style={{ animationDelay: `${index * 0.1}s` }}
onClick={() => goToDetail(item.id)}
>
<View className='card-cover'>
{item.static_image_url ? (
<Image src={item.static_image_url} mode='aspectFill' className='card-img' />
) : (
<View className='placeholder-img'>
<Text className='icon-rocket'>🚀</Text>
<View className='radar-scan'></View>
</View>
)}
<View className='card-overlay' />

View File

@@ -1,51 +1,154 @@
.page-container {
min-height: 100vh;
background-color: #f7f8fa;
padding: 15px;
padding-bottom: 80px;
background-color: #050505;
color: #fff;
padding-bottom: 120px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.section {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
margin: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(10px);
position: relative;
.section-title {
font-size: 28px;
font-weight: bold;
color: #fff;
margin-bottom: 20px;
display: block;
&::before {
content: '';
display: inline-block;
width: 6px;
height: 24px;
background: #00b96b;
margin-right: 12px;
vertical-align: middle;
border-radius: 3px;
}
}
}
.delivery-type-section {
display: flex;
padding: 10px;
gap: 10px;
.type-item {
flex: 1;
text-align: center;
padding: 16px 0;
font-size: 28px;
color: #888;
border-radius: 10px;
transition: all 0.3s;
&.active {
background: #00b96b;
color: #fff;
font-weight: bold;
}
}
}
.address-section {
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
justify-content: space-between;
.row {
margin-bottom: 8px;
.name { font-size: 16px; font-weight: bold; margin-right: 10px; }
.phone { font-size: 14px; color: #666; }
.address-info {
flex: 1;
.user-info {
font-size: 30px;
font-weight: bold;
margin-bottom: 8px;
.phone { margin-left: 20px; color: #888; font-weight: normal; font-size: 26px; }
}
.address-text {
font-size: 26px;
color: #aaa;
line-height: 1.4;
}
.placeholder {
color: #00b96b;
font-size: 30px;
font-weight: bold;
}
}
.addr { font-size: 14px; color: #333; line-height: 1.4; }
.placeholder-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
.arrow {
font-size: 30px;
color: #666;
margin-left: 20px;
}
.placeholder { font-size: 16px; color: #00b96b; }
}
.product-section {
.p-name { font-size: 16px; font-weight: 500; margin-bottom: 10px; display: block; }
.row { display: flex; justify-content: space-between; align-items: center; }
.p-price { font-size: 16px; color: #333; }
.p-qty { font-size: 14px; color: #999; }
padding: 0; // Remove padding for list
overflow: hidden;
.divider { height: 1px; background: #eee; margin: 15px 0; }
.section-title { margin: 24px 24px 10px; }
.total-row {
.total-price { font-size: 20px; color: #ff4d4f; font-weight: bold; }
}
.product-item {
display: flex;
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&:last-child { border-bottom: none; }
.p-img {
width: 120px;
height: 120px;
border-radius: 8px;
background: #000;
margin-right: 20px;
object-fit: cover;
}
.p-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.p-name { font-size: 28px; color: #fff; font-weight: bold; }
.p-desc { font-size: 24px; color: #888; }
.p-meta {
display: flex;
justify-content: space-between;
align-items: center;
.p-price { font-size: 30px; color: #00b96b; font-weight: bold; }
.p-qty { font-size: 26px; color: #888; }
}
}
}
}
.summary-section {
.row {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
font-size: 28px;
color: #888;
&.total {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
font-weight: bold;
font-size: 32px;
.price { color: #00b96b; font-size: 40px; }
}
}
}
.bottom-bar {
@@ -53,22 +156,36 @@
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 10px 20px;
border-top: 1px solid #eee;
height: 110px;
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 30px;
z-index: 100;
.total-label { font-size: 28px; color: #fff; margin-right: 20px; }
.total-price { font-size: 40px; color: #00b96b; font-weight: bold; margin-right: 30px; }
.btn-submit {
background: #00b96b;
color: #fff;
border-radius: 22px;
background: linear-gradient(135deg, #00b96b 0%, #00f0ff 100%);
color: #000;
border-radius: 40px;
padding: 0 60px;
height: 80px;
line-height: 80px;
font-size: 32px;
font-weight: bold;
border: none;
font-size: 16px;
height: 44px;
line-height: 44px;
box-shadow: 0 0 20px rgba(0, 185, 107, 0.3);
&:active { transform: scale(0.98); }
&.disabled {
background: #333;
color: #666;
box-shadow: none;
}
}
}
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -1,98 +1,220 @@
import { View, Text, Button } from '@tarojs/components'
import { View, Text, Image, ScrollView, Button } from '@tarojs/components'
import Taro, { useRouter, useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { getConfigDetail, createOrder } from '../../api'
import { getSelectedItems, removeItem } from '../../utils/cart'
import './checkout.scss'
export default function Checkout() {
const router = useRouter()
const { id, quantity } = router.params
const [product, setProduct] = useState<any>(null)
const params = router.params
const [items, setItems] = useState<any[]>([])
const [address, setAddress] = useState<any>(null)
const [contact, setContact] = useState({ name: '', phone: '' })
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [userAddress, setUserAddress] = useState<any>(null)
const [loading, setLoading] = useState(true)
const PICKUP_ADDRESS = {
userName: '云南量迹科技有限公司',
telNumber: '18585164448',
provinceName: '云南省',
cityName: '昆明市',
countyName: '西山区',
detailInfo: '永昌街道办事处云纺国际商厦 B 座 1406 号'
}
useLoad(async () => {
if (id) {
const res = await getConfigDetail(Number(id))
setProduct(res)
if (params.from === 'cart') {
const cartItems = getSelectedItems()
if (cartItems.length === 0) {
Taro.navigateBack()
return
}
setItems(cartItems)
setLoading(false)
} else if (params.id) {
try {
const res = await getConfigDetail(params.id)
setItems([{
id: res.id,
name: res.name,
price: res.price,
image: res.static_image_url || res.detail_image_url,
quantity: Number(params.quantity) || 1,
description: res.description
}])
} catch (err) {
console.error(err)
Taro.showToast({ title: '商品加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
})
const chooseAddress = async () => {
if (deliveryType === 'pickup') return
try {
const res = await Taro.chooseAddress()
setAddress(res)
setContact({ name: res.userName, phone: res.telNumber })
} catch (e) {
Taro.showToast({ title: '需要授权获取地址', icon: 'none' })
const res = await Taro.chooseAddress()
setAddress(res)
setUserAddress(res)
} catch (err) {
console.error(err)
// User cancelled or auth denied
}
}
const handleTypeChange = (type: 'delivery' | 'pickup') => {
if (type === deliveryType) return
setDeliveryType(type)
if (type === 'pickup') {
setAddress(PICKUP_ADDRESS)
} else {
setAddress(userAddress)
}
}
const totalPrice = useMemo(() => {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}, [items])
const submitOrder = async () => {
if (!address) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
Taro.showLoading({ title: '提交中...' })
try {
Taro.showLoading({ title: '正在下单...' })
const orderPromises = items.map(item => {
const orderData = {
goodid: product.id,
quantity: Number(quantity || 1),
customer_name: contact.name,
phone_number: contact.phone,
shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`,
// ref_code: Taro.getStorageSync('ref_code')
goodid: item.id,
quantity: item.quantity,
customer_name: address.userName,
phone_number: address.telNumber,
shipping_address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}`
}
return createOrder(orderData)
})
const res = await createOrder(orderData)
Taro.hideLoading()
const results = await Promise.all(orderPromises)
// If from cart, remove bought items
if (params.from === 'cart') {
items.forEach(item => removeItem(item.id))
}
Taro.hideLoading()
if (results.length === 1) {
// Single order, go to payment
const orderId = results[0].order_id
Taro.redirectTo({
url: `/pages/order/payment?id=${orderId}`
})
} else {
// Multiple orders
Taro.showModal({
title: '下单成功',
content: `成功创建 ${results.length} 个订单,请前往订单列表支付`,
showCancel: false,
confirmText: '去支付',
success: () => {
Taro.redirectTo({ url: '/pages/order/list' })
}
})
}
if (res.order_id) {
Taro.redirectTo({ url: `/pages/order/payment?id=${res.order_id}` })
}
} catch (err) {
Taro.hideLoading()
console.error(err)
Taro.hideLoading()
console.error(err)
Taro.showToast({ title: '下单失败', icon: 'none' })
}
}
if (!product) return <View>Loading...</View>
if (loading) return <View className='page-container'><View className='section'><Text>Loading...</Text></View></View>
return (
<View className='page-container'>
<View className='page-container'>
<ScrollView scrollY style={{height: 'calc(100vh - 120px)'}}>
{/* Delivery Type Section */}
<View className='section delivery-type-section'>
<View
className={`type-item ${deliveryType === 'delivery' ? 'active' : ''}`}
onClick={() => handleTypeChange('delivery')}
>
</View>
<View
className={`type-item ${deliveryType === 'pickup' ? 'active' : ''}`}
onClick={() => handleTypeChange('pickup')}
>
</View>
</View>
{/* Address Section */}
<View className='section address-section' onClick={chooseAddress}>
{address ? (
<View>
<View className='row'>
<Text className='name'>{contact.name}</Text>
<Text className='phone'>{contact.phone}</Text>
<View className='address-info'>
<View className='user-info'>
<Text>{address.userName}</Text>
<Text className='phone'>{address.telNumber}</Text>
</View>
<View className='address-text'>
{address.provinceName}{address.cityName}{address.countyName}{address.detailInfo}
</View>
<Text className='addr'>{address.provinceName}{address.cityName}{address.countyName}{address.detailInfo}</Text>
</View>
) : (
<View className='placeholder-container'>
<Text className='placeholder'>+ </Text>
<View className='address-info'>
<Text className='placeholder'>+ </Text>
</View>
)}
{deliveryType === 'delivery' && <Text className='arrow'></Text>}
</View>
{/* Products Section */}
<View className='section product-section'>
<Text className='p-name'>{product.name}</Text>
<View className='row'>
<Text className='p-price'>¥{product.price}</Text>
<Text className='p-qty'>x {quantity}</Text>
</View>
<View className='divider' />
<View className='row total-row'>
<Text></Text>
<Text className='total-price'>¥{(product.price * (Number(quantity) || 1)).toFixed(2)}</Text>
</View>
<Text className='section-title'></Text>
{items.map((item, idx) => (
<View key={idx} className='product-item'>
<Image src={item.image} className='p-img' mode='aspectFill' />
<View className='p-info'>
<Text className='p-name'>{item.name}</Text>
<Text className='p-desc'>{item.description}</Text>
<View className='p-meta'>
<Text className='p-price'>¥{item.price}</Text>
<Text className='p-qty'>x{item.quantity}</Text>
</View>
</View>
</View>
))}
</View>
<View className='bottom-bar safe-area-bottom'>
<Button className='btn-submit' onClick={submitOrder}></Button>
{/* Summary Section */}
<View className='section summary-section'>
<View className='row'>
<Text></Text>
<Text>¥{totalPrice}</Text>
</View>
<View className='row'>
<Text></Text>
<Text>¥0</Text>
</View>
<View className='row total'>
<Text></Text>
<Text className='price'>¥{totalPrice}</Text>
</View>
</View>
</View>
</ScrollView>
{/* Bottom Bar */}
<View className='bottom-bar'>
<Text className='total-label'>{items.length}</Text>
<Text className='total-price'>¥{totalPrice}</Text>
<Button className='btn-submit' onClick={submitOrder}></Button>
</View>
</View>
)
}

View File

@@ -128,59 +128,158 @@
.process-section {
margin-top: 60px;
padding: 40px 20px;
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,185,107,0.05) 100%);
border-radius: 30px;
border: 1px solid rgba(255,255,255,0.05);
position: relative;
overflow: hidden;
// Background Tech Grid
&::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
linear-gradient(rgba(0, 185, 107, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 185, 107, 0.03) 1px, transparent 1px);
background-size: 20px 20px;
z-index: 0;
}
.section-title {
color: #fff;
text-align: center;
font-size: 36px;
font-weight: bold;
margin-bottom: 40px;
margin-bottom: 60px;
display: block;
text-shadow: 0 0 10px rgba(0, 185, 107, 0.5);
text-shadow: 0 0 15px rgba(0, 185, 107, 0.8);
position: relative;
z-index: 1;
letter-spacing: 2px;
&::after {
content: '';
display: block;
width: 60px;
height: 4px;
background: #00b96b;
margin: 15px auto 0;
border-radius: 2px;
box-shadow: 0 0 10px #00b96b;
}
}
.process-steps {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 20px;
flex-direction: column;
position: relative;
z-index: 1;
padding: 0 20px;
// Vertical connecting line
&::before {
content: '';
position: absolute;
top: 20px;
bottom: 20px;
left: 60px; // Center of the icon (40px + padding)
width: 2px;
background: rgba(255, 255, 255, 0.1);
z-index: 0;
}
// Moving signal on the line
&::after {
content: '';
position: absolute;
top: 20px;
left: 60px;
width: 2px;
height: 100px;
background: linear-gradient(to bottom, transparent, #00b96b, transparent);
animation: signalFlow 3s infinite linear;
z-index: 0;
}
}
.step-item {
width: 48%; // 2 columns
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 30px;
margin-bottom: 40px;
position: relative;
&:last-child { margin-bottom: 0; }
.step-icon {
width: 80px;
height: 80px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(0, 185, 107, 0.3);
border-radius: 20px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(0, 185, 107, 0.5);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
color: #00b96b;
font-size: 32px;
font-size: 36px;
font-weight: bold;
margin-right: 30px;
position: relative;
z-index: 1;
box-shadow: 0 0 15px rgba(0, 185, 107, 0.2);
transition: all 0.3s ease;
// Pulse effect for icon
&::before {
content: '';
position: absolute;
top: -5px; bottom: -5px; left: -5px; right: -5px;
border-radius: 24px;
border: 1px solid rgba(0, 185, 107, 0.3);
animation: pulseBorder 2s infinite;
}
}
.step-title {
color: #fff;
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
}
// Content Card
.step-content-wrapper {
flex: 1;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.05);
border-left: 4px solid #00b96b;
padding: 20px 24px;
border-radius: 0 16px 16px 0;
backdrop-filter: blur(5px);
transform: translateX(0);
transition: all 0.3s ease;
.step-desc {
color: #666;
font-size: 24px;
&:active {
background: rgba(255, 255, 255, 0.05);
transform: translateX(5px);
}
.step-title {
color: #fff;
font-size: 30px;
font-weight: bold;
margin-bottom: 8px;
display: block;
text-shadow: 0 0 5px rgba(0,0,0,0.5);
}
.step-desc {
color: #888;
font-size: 24px;
line-height: 1.4;
}
}
}
}
@keyframes signalFlow {
0% { top: 0; opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
@keyframes pulseBorder {
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(1.15); opacity: 0; }
}

View File

@@ -91,8 +91,10 @@ export default function ServicesIndex() {
].map((step) => (
<View key={step.id} className='step-item'>
<View className='step-icon'><Text>{step.id}</Text></View>
<Text className='step-title'>{step.title}</Text>
<Text className='step-desc'>{step.desc}</Text>
<View className='step-content-wrapper'>
<Text className='step-title'>{step.title}</Text>
<Text className='step-desc'>{step.desc}</Text>
</View>
</View>
))}
</View>

View File

@@ -1,53 +1,207 @@
.page-container {
min-height: 100vh;
background-color: #f7f8fa;
background-color: #050505;
color: #fff;
padding: 30px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.header {
background: #fff;
padding: 40px 20px;
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(0, 185, 107, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0); }
}
@keyframes float {
0% { transform: translateY(0); }
50% { transform: translateY(-5px); }
100% { transform: translateY(0); }
}
.profile-card {
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
border: 1px solid rgba(255,255,255,0.05);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 40px;
display: flex;
align-items: center;
margin-bottom: 20px;
margin-bottom: 30px;
position: relative;
overflow: hidden;
.avatar {
width: 60px;
height: 60px;
border-radius: 30px;
margin-right: 15px;
background: #eee;
.card-bg-effect {
position: absolute;
top: -50%;
right: -20%;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(0, 185, 107, 0.2) 0%, transparent 70%);
filter: blur(40px);
z-index: 0;
}
.nickname {
font-size: 18px;
font-weight: bold;
color: #333;
.avatar-container {
position: relative;
margin-right: 30px;
z-index: 1;
.avatar {
width: 120px;
height: 120px;
border-radius: 60px;
border: 2px solid rgba(0, 185, 107, 0.5);
background: #000;
}
.online-dot {
position: absolute;
bottom: 5px;
right: 5px;
width: 24px;
height: 24px;
background: #00b96b;
border-radius: 50%;
border: 3px solid #111;
animation: pulse 2s infinite;
}
}
.info-col {
flex: 1;
z-index: 1;
display: flex;
flex-direction: column;
.nickname {
font-size: 36px;
font-weight: bold;
color: #fff;
margin-bottom: 8px;
text-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.uid {
font-size: 24px;
color: #666;
margin-bottom: 20px;
font-family: monospace;
}
.btn-login {
background: rgba(0, 185, 107, 0.2);
border: 1px solid #00b96b;
color: #00b96b;
font-size: 24px;
border-radius: 30px;
padding: 0 30px;
height: 60px;
line-height: 58px;
margin: 0;
width: fit-content;
&:active { background: rgba(0, 185, 107, 0.3); }
}
}
}
.menu {
background: #fff;
.item {
padding: 15px 20px;
border-bottom: 1px solid #eee;
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
position: relative;
margin-bottom: 30px;
padding: 0 10px;
&:last-child { border-bottom: none; }
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.arrow { color: #ccc; }
.btn-contact {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
.stat-val { font-size: 36px; font-weight: bold; color: #fff; margin-bottom: 5px; }
.stat-lbl { font-size: 24px; color: #666; }
}
}
.service-container {
padding-bottom: 40px;
.service-group {
margin-bottom: 40px;
.group-title {
display: block;
font-size: 32px;
font-weight: bold;
color: #fff;
margin-bottom: 20px;
padding-left: 10px;
border-left: 4px solid #00b96b;
line-height: 1;
}
.grid-layout {
display: flex;
flex-wrap: wrap;
gap: 20px;
.grid-item {
width: calc(33.33% - 14px); // 3 items per row, accounting for gap
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 30px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
box-sizing: border-box;
backdrop-filter: blur(10px);
transition: all 0.2s ease;
&:active {
background: rgba(255, 255, 255, 0.08);
transform: scale(0.95);
}
.icon-box {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 185, 107, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
.icon { font-size: 40px; }
}
.item-title {
font-size: 26px;
color: #ddd;
text-align: center;
}
.contact-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
opacity: 0;
}
}
}
}
}
.version-info {
margin-top: 60px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text {
font-size: 20px;
color: #333;
}
}
}

View File

@@ -13,33 +13,99 @@ export default function UserIndex() {
const goOrders = () => Taro.navigateTo({ url: '/pages/order/list' })
const goDistributor = () => Taro.navigateTo({ url: '/subpackages/distributor/index' })
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
const handleAddress = async () => {
try { await Taro.chooseAddress() } catch(e) {}
}
const login = () => {
// Trigger login again if needed
Taro.reLaunch({ url: '/pages/index/index' })
}
const serviceGroups = [
{
title: '基础服务',
items: [
{ title: '我的订单', icon: '📦', action: goOrders },
{ title: '地址管理', icon: '📍', action: handleAddress },
{ title: '新增地址', icon: '📝', action: handleAddress },
]
},
{
title: '分销中心',
items: [
{ title: '分销首页', icon: '⚡', action: goDistributor },
{ title: '推广邀请', icon: '🤝', action: goInvite },
{ title: '佣金提现', icon: '💰', action: goWithdraw },
]
},
{
title: '其他',
items: [
{ title: '联系客服', icon: '🎧', isContact: true }
]
}
]
const stats = [
{ label: '余额', value: '0.00' },
{ label: '积分', value: '0' },
{ label: '优惠券', value: '0' }
]
return (
<View className='page-container'>
<View className='header'>
<Image src={userInfo?.avatar_url || 'https://via.placeholder.com/100'} className='avatar' />
<Text className='nickname'>{userInfo?.nickname || '未登录'}</Text>
{!userInfo && <Button size='mini' onClick={login}></Button>}
{/* Profile Card */}
<View className='profile-card'>
<View className='avatar-container'>
<Image src={userInfo?.avatar_url || 'https://via.placeholder.com/150/00b96b/FFFFFF?text=USER'} className='avatar' />
{userInfo && <View className='online-dot' />}
</View>
<View className='info-col'>
<Text className='nickname'>{userInfo?.nickname || '未登录用户'}</Text>
<Text className='uid'>ID: {userInfo ? '888888' : '----'}</Text>
{!userInfo && (
<Button className='btn-login' onClick={login}> / </Button>
)}
</View>
<View className='card-bg-effect' />
</View>
<View className='menu'>
<View className='item' onClick={goOrders}>
<Text></Text>
<Text className='arrow'>></Text>
</View>
<View className='item' onClick={goDistributor}>
<Text></Text>
<Text className='arrow'>></Text>
</View>
<View className='item'>
<Text></Text>
<Button openType='contact' className='btn-contact' />
<Text className='arrow'>></Text>
</View>
{/* Stats Row */}
<View className='stats-row'>
{stats.map((item, idx) => (
<View key={idx} className='stat-item'>
<Text className='stat-val'>{item.value}</Text>
<Text className='stat-lbl'>{item.label}</Text>
</View>
))}
</View>
{/* Service Groups */}
<View className='service-container'>
{serviceGroups.map((group, gIdx) => (
<View key={gIdx} className='service-group'>
<Text className='group-title'>{group.title}</Text>
<View className='grid-layout'>
{group.items.map((item, idx) => (
<View key={idx} className='grid-item' onClick={item.action}>
<View className='icon-box'>
<Text className='icon'>{item.icon}</Text>
</View>
<Text className='item-title'>{item.title}</Text>
{item.isContact && <Button openType='contact' className='contact-overlay' />}
</View>
))}
</View>
</View>
))}
</View>
<View className='version-info'>
<Text>Quant Speed Market v1.0.0</Text>
<Text>Powered by Taro & React</Text>
</View>
</View>
)

View File

@@ -30,6 +30,7 @@ export default function DistributorIndex() {
const goInvite = () => Taro.navigateTo({ url: '/subpackages/distributor/invite' })
const goWithdraw = () => Taro.navigateTo({ url: '/subpackages/distributor/withdraw' })
const showComingSoon = () => Taro.showToast({ title: '功能开发中', icon: 'none' })
if (loading) return <View>Loading...</View>
if (!info) return <View>Error</View>
@@ -62,11 +63,11 @@ export default function DistributorIndex() {
<Text>广</Text>
<Text className='arrow'>></Text>
</View>
<View className='menu-item'>
<View className='menu-item' onClick={showComingSoon}>
<Text></Text>
<Text className='arrow'>></Text>
</View>
<View className='menu-item'>
<View className='menu-item' onClick={showComingSoon}>
<Text></Text>
<Text className='arrow'>></Text>
</View>

View File

@@ -0,0 +1,90 @@
import Taro from '@tarojs/taro'
const CART_KEY = 'MARKET_CART'
export interface CartItem {
id: number
name: string
price: number
image: string
quantity: number
selected: boolean
stock: number
description: string
}
export const getCart = (): CartItem[] => {
return Taro.getStorageSync(CART_KEY) || []
}
export const setCart = (cart: CartItem[]) => {
Taro.setStorageSync(CART_KEY, cart)
}
export const addToCart = (product: any, quantity: number = 1) => {
const cart = getCart()
const existing = cart.find(item => item.id === product.id)
if (existing) {
existing.quantity += quantity
if (existing.quantity > product.stock) existing.quantity = product.stock
} else {
cart.push({
id: product.id,
name: product.name,
price: product.price,
image: product.static_image_url || product.detail_image_url,
quantity: quantity,
selected: true,
stock: product.stock,
description: product.description
})
}
setCart(cart)
Taro.showToast({ title: '已加入购物车', icon: 'success' })
}
export const updateQuantity = (id: number, quantity: number) => {
const cart = getCart()
const item = cart.find(i => i.id === id)
if (item) {
item.quantity = quantity
if (item.quantity > item.stock) item.quantity = item.stock
if (item.quantity < 1) item.quantity = 1
setCart(cart)
}
return cart
}
export const removeItem = (id: number) => {
const cart = getCart()
const newCart = cart.filter(i => i.id !== id)
setCart(newCart)
return newCart
}
export const toggleSelect = (id: number) => {
const cart = getCart()
const item = cart.find(i => i.id === id)
if (item) {
item.selected = !item.selected
setCart(cart)
}
return cart
}
export const toggleSelectAll = (selected: boolean) => {
const cart = getCart()
cart.forEach(item => item.selected = selected)
setCart(cart)
return cart
}
export const getSelectedItems = () => {
return getCart().filter(item => item.selected)
}
export const getCartCount = () => {
return getCart().reduce((sum, item) => sum + item.quantity, 0)
}