sms
All checks were successful
Deploy to Server / deploy (push) Successful in 3s

This commit is contained in:
jeremygan2021
2026-02-16 19:59:45 +08:00
parent 481a1d24f0
commit 91d82b78b5
46 changed files with 247 additions and 5482 deletions

View File

@@ -6,7 +6,7 @@ from django.urls import path, reverse
from django.shortcuts import redirect from django.shortcuts import redirect
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display from unfold.decorators import display
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber
import qrcode import qrcode
from io import BytesIO from io import BytesIO
import base64 import base64
@@ -478,3 +478,9 @@ class WithdrawalAdmin(ModelAdmin):
'fields': ('created_at', 'updated_at') 'fields': ('created_at', 'updated_at')
}), }),
) )
@admin.register(AdminPhoneNumber)
class AdminPhoneNumberAdmin(ModelAdmin):
list_display = ('name', 'phone_number', 'is_active', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'phone_number')

View File

@@ -3,3 +3,7 @@ from django.apps import AppConfig
class ShopConfig(AppConfig): class ShopConfig(AppConfig):
name = 'shop' name = 'shop'
verbose_name = "商城管理"
def ready(self):
import shop.signals

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-02-16 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0030_alter_esp32config_options_alter_service_options_and_more'),
]
operations = [
migrations.CreateModel(
name='AdminPhoneNumber',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name='管理员姓名')),
('phone_number', models.CharField(max_length=20, verbose_name='手机号')),
('is_active', models.BooleanField(default=True, verbose_name='是否接收通知')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '管理员通知手机号',
'verbose_name_plural': '管理员通知手机号',
},
),
]

View File

@@ -402,3 +402,21 @@ class CourseEnrollment(models.Model):
class Meta: class Meta:
verbose_name = "课程报名" verbose_name = "课程报名"
verbose_name_plural = "课程报名管理" verbose_name_plural = "课程报名管理"
class AdminPhoneNumber(models.Model):
"""
管理员通知手机号配置
用于接收订单支付成功等重要通知
"""
name = models.CharField(max_length=50, verbose_name="管理员姓名")
phone_number = models.CharField(max_length=20, verbose_name="手机号")
is_active = models.BooleanField(default=True, verbose_name="是否接收通知")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def __str__(self):
return f"{self.name} - {self.phone_number}"
class Meta:
verbose_name = "管理员通知手机号"
verbose_name_plural = "管理员通知手机号"

54
backend/shop/signals.py Normal file
View File

@@ -0,0 +1,54 @@
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from .models import Order
from .sms_utils import notify_admins_order_paid, notify_user_order_paid, notify_user_order_shipped
@receiver(pre_save, sender=Order)
def track_order_changes(sender, instance, **kwargs):
"""
在保存之前检查状态变化
"""
if instance.pk:
try:
old_instance = Order.objects.get(pk=instance.pk)
# 检查是否从非支付状态变为支付状态
if old_instance.status != 'paid' and instance.status == 'paid':
instance._was_paid = True
# 检查是否发货 (状态变为 shipped 且有单号)
# 或者已经是 shipped 状态但刚填入单号
if instance.status == 'shipped' and instance.tracking_number:
if old_instance.status != 'shipped' or not old_instance.tracking_number:
instance._was_shipped = True
except Order.DoesNotExist:
pass
@receiver(post_save, sender=Order)
def send_order_notifications(sender, instance, created, **kwargs):
"""
在保存之后发送通知
"""
if created:
return
# 1. 处理支付成功通知
if getattr(instance, '_was_paid', False):
try:
print(f"订单 {instance.id} 支付成功,触发短信通知流程...")
notify_admins_order_paid(instance)
notify_user_order_paid(instance)
# 清除标记防止重复发送 (虽然实例通常是新的,但保险起见)
instance._was_paid = False
except Exception as e:
print(f"发送支付成功短信失败: {str(e)}")
# 2. 处理发货通知
if getattr(instance, '_was_shipped', False):
try:
print(f"订单 {instance.id} 已发货,触发短信通知流程...")
notify_user_order_shipped(instance)
instance._was_shipped = False
except Exception as e:
print(f"发送发货短信失败: {str(e)}")

98
backend/shop/sms_utils.py Normal file
View File

@@ -0,0 +1,98 @@
import requests
import threading
import json
from .models import AdminPhoneNumber
# SMS API Configuration
SMS_API_URL = "https://data.tangledup-ai.com/api/send-sms/diy"
SIGN_NAME = "叠加态科技云南"
def send_sms(phone_number, template_code, template_params):
"""
通用发送短信函数 (异步)
"""
def _send():
try:
payload = {
"phone_number": phone_number,
"template_code": template_code,
"sign_name": SIGN_NAME,
"additionalProp1": template_params
}
headers = {
"Content-Type": "application/json",
"accept": "application/json"
}
# print(f"Sending SMS to {phone_number} with params: {template_params}")
response = requests.post(SMS_API_URL, json=payload, headers=headers, timeout=15)
print(f"SMS Response for {phone_number}: {response.status_code} - {response.text}")
except Exception as e:
print(f"发送短信异常: {str(e)}")
threading.Thread(target=_send).start()
def notify_admins_order_paid(order):
"""
通知管理员有新订单支付成功
"""
# 获取激活的管理员手机号最多3个
admins = AdminPhoneNumber.objects.filter(is_active=True)[:3]
if not admins.exists():
print("未配置管理员手机号,跳过管理员通知")
return
# 构造参数
# 模板变量: consignee, order_id, address
# order_id 格式要求: "订单编号/电话号码"
params = {
"consignee": order.customer_name or "未填写",
"order_id": f"{order.id}/{order.phone_number}",
"address": order.shipping_address or "无地址"
}
print(f"准备发送管理员通知,共 {admins.count()}")
for admin in admins:
send_sms(admin.phone_number, "SMS_501735480", params)
def notify_user_order_paid(order):
"""
通知用户下单成功 (支付成功)
"""
if not order.phone_number:
return
# 模板变量: user_nick, address
# 尝试获取用户昵称,如果没有则使用收货人姓名
user_nick = order.customer_name
if order.wechat_user and order.wechat_user.nickname:
user_nick = order.wechat_user.nickname
params = {
"user_nick": user_nick or "用户",
"address": order.shipping_address or "无地址"
}
print(f"准备发送用户支付成功通知: {order.phone_number}")
send_sms(order.phone_number, "SMS_501850529", params)
def notify_user_order_shipped(order):
"""
通知用户已发货
"""
if not order.phone_number:
return
# 模板变量: user_nick, address, delivery_company, order_id (这里指快递单号)
user_nick = order.customer_name
if order.wechat_user and order.wechat_user.nickname:
user_nick = order.wechat_user.nickname
params = {
"user_nick": user_nick or "用户",
"address": order.shipping_address or "无地址",
"delivery_company": order.courier_name or "快递",
"order_id": order.tracking_number or "暂无单号"
}
print(f"准备发送用户发货通知: {order.phone_number}")
send_sms(order.phone_number, "SMS_501650557", params)

39
deploy_market_page 2.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# 定义关键变量,方便后续维护修改
TARGET_DIR="~/data/dev/market_page"
SUDO_PASSWORD="123quant-speed"
# 脚本执行出错时立即退出
set -e
# 1. 切换到目标目录(先解析 ~ 为实际家目录)
echo "===== 切换到目标目录: $TARGET_DIR ====="
RESOLVED_DIR=$(eval echo $TARGET_DIR)
cd $RESOLVED_DIR || {
echo "错误:目录 $RESOLVED_DIR 不存在!"
exit 1
}
# 2. 停止并移除 Docker 容器(自动输入 sudo 密码)
echo -e "\n===== 停止 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose down
# 3. 删除 Docker 镜像(说明:这里默认删除 compose 关联的镜像,也可指定镜像名)
echo -e "\n===== 删除 Docker 镜像 ====="
# 方式1删除 compose.yml 中定义的所有镜像(推荐)
echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all
# 方式2如果你想删除指定镜像替换上面这行示例需修改为你的镜像名
# echo $SUDO_PASSWORD | sudo -S docker rmi -f your-image-name:tag
# 4. 拉取 Git 最新代码
echo -e "\n===== 拉取 Git 代码 ====="
git pull || {
echo "警告Git pull 失败(可能是本地有未提交的修改),脚本继续执行..."
}
# 5. 重新启动 Docker 容器(后台运行)
echo -e "\n===== 启动 Docker 容器 ====="
echo $SUDO_PASSWORD | sudo -S docker compose up -d
echo -e "\n===== 操作完成!====="

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

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

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

View File

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

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -1,49 +0,0 @@
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './context/AuthContext';
import Layout from './components/Layout';
import Home from './pages/Home';
import ProductDetail from './pages/ProductDetail';
import Payment from './pages/Payment';
import AIServices from './pages/AIServices';
import ServiceDetail from './pages/ServiceDetail';
import VCCourses from './pages/VCCourses';
import VCCourseDetail from './pages/VCCourseDetail';
import MyOrders from './pages/MyOrders';
import ForumList from './pages/ForumList';
import ForumDetail from './pages/ForumDetail';
import ActivityDetail from './pages/activity/Detail';
import 'antd/dist/reset.css';
import './App.css';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/services" element={<AIServices />} />
<Route path="/services/:id" element={<ServiceDetail />} />
<Route path="/courses" element={<VCCourses />} />
<Route path="/courses/:id" element={<VCCourseDetail />} />
<Route path="/forum" element={<ForumList />} />
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/activity/:id" element={<ActivityDetail />} />
<Route path="/my-orders" element={<MyOrders />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/payment/:orderId" element={<Payment />} />
</Routes>
</Layout>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
)
}
export default App

View File

@@ -1,53 +0,0 @@
// Framer Motion Animation Variants
export const fadeInUp = {
hidden: { opacity: 0, y: 30 },
visible: (custom = 0) => ({
opacity: 1,
y: 0,
transition: {
delay: custom * 0.08,
duration: 0.6,
ease: [0.22, 1, 0.36, 1], // Custom easing
},
}),
};
export const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
export const hoverScale = {
hover: {
scale: 1.03,
boxShadow: "0px 10px 20px rgba(0, 0, 0, 0.2)",
transition: { duration: 0.3 },
},
};
export const pageTransition = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
transition: { duration: 0.3 },
};
export const buttonTap = {
scale: 0.95,
};
export const imageFadeIn = {
hidden: { opacity: 0, scale: 1.1 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.5 }
},
};

View File

@@ -1,73 +0,0 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
timeout: 8000, // 增加超时时间到 10秒
headers: {
'Content-Type': 'application/json',
}
});
// 请求拦截器:自动附加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
export const getConfigs = () => api.get('/configs/');
export const createOrder = (data) => api.post('/orders/', data);
export const nativePay = (data) => api.post('/pay/', data);
export const getOrder = (id) => api.get(`/orders/${id}/`);
export const queryOrderStatus = (id) => api.get(`/orders/${id}/query_status/`);
export const initiatePayment = (orderId) => api.post(`/orders/${orderId}/initiate_payment/`);
export const confirmPayment = (orderId) => api.post(`/orders/${orderId}/confirm_payment/`);
export const getServices = () => api.get('/services/');
export const getServiceDetail = (id) => api.get(`/services/${id}/`);
export const createServiceOrder = (data) => api.post('/service-orders/', data);
export const getVCCourses = () => api.get('/courses/');
export const getVCCourseDetail = (id) => api.get(`/courses/${id}/`);
export const enrollCourse = (data) => api.post('/course-enrollments/', data);
export const sendSms = (data) => api.post('/auth/send-sms/', data);
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
export const phoneLogin = (data) => api.post('/auth/phone-login/', data);
export const getUserInfo = () => api.get('/users/me/');
export const updateUserInfo = (data) => api.post('/wechat/update/', data);
export const uploadUserAvatar = (data) => {
// 使用 axios 直接请求外部接口,避免 base URL 和拦截器干扰
return axios.post('https://data.tangledup-ai.com/upload?folder=uploads/market/avator', data, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
};
// Community / Forum API
export const getTopics = (params) => api.get('/community/topics/', { params });
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
export const createTopic = (data) => api.post('/community/topics/', data);
export const updateTopic = (id, data) => api.patch(`/community/topics/${id}/`, data);
export const getReplies = (params) => api.get('/community/replies/', { params });
export const createReply = (data) => api.post('/community/replies/', data);
export const uploadMedia = (data) => {
return api.post('/community/media/', data, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
};
export const getStarUsers = () => api.get('/users/stars/');
export const getMyPaidItems = () => api.get('/users/paid-items/');
export const getAnnouncements = () => api.get('/community/announcements/');
export const getActivities = () => api.get('/community/activities/');
export const getActivityDetail = (id) => api.get(`/community/activities/${id}/`);
export const signUpActivity = (id, data) => api.post(`/community/activities/${id}/signup/`, data);
export const getMySignups = () => api.get('/community/activities/my_signups/');
export default api;

View File

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,281 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { createTopic, updateTopic, uploadMedia, getMyPaidItems } from '../api';
import MDEditor from '@uiw/react-md-editor';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import 'katex/dist/katex.css';
const { Option } = Select;
const CreateTopicModal = ({ visible, onClose, onSuccess, initialValues, isEditMode, topicId }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] });
const [uploading, setUploading] = useState(false);
const [mediaIds, setMediaIds] = useState([]);
// eslint-disable-next-line no-unused-vars
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
const [content, setContent] = useState("");
useEffect(() => {
if (visible) {
fetchPaidItems();
if (isEditMode && initialValues) {
// Edit Mode: Populate form with initial values
form.setFieldsValue({
title: initialValues.title,
category: initialValues.category,
});
setContent(initialValues.content);
form.setFieldValue('content', initialValues.content);
// Handle related item
let relatedVal = null;
if (initialValues.related_product) relatedVal = `config_${initialValues.related_product.id || initialValues.related_product}`;
else if (initialValues.related_course) relatedVal = `course_${initialValues.related_course.id || initialValues.related_course}`;
else if (initialValues.related_service) relatedVal = `service_${initialValues.related_service.id || initialValues.related_service}`;
if (relatedVal) form.setFieldValue('related_item', relatedVal);
// Note: We start with empty *new* media IDs.
// Existing media is embedded in content or stored in DB, we don't need to re-upload or track them here unless we want to delete them (which is complex).
// For now, we just allow adding NEW media.
setMediaIds([]);
setMediaList([]);
} else {
// Create Mode: Reset form
setMediaIds([]);
setMediaList([]);
setContent("");
form.resetFields();
form.setFieldsValue({ content: "", category: 'discussion' });
}
}
}, [visible, isEditMode, initialValues, form]);
const fetchPaidItems = async () => {
try {
const res = await getMyPaidItems();
setPaidItems(res.data);
} catch (error) {
console.error("Failed to fetch paid items", error);
}
};
const handleUpload = async (file) => {
const formData = new FormData();
formData.append('file', file);
// 默认为 image如果需要支持视频需根据 file.type 判断
formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image');
setUploading(true);
try {
const res = await uploadMedia(formData);
// 记录上传的媒体 ID
if (res.data.id) {
setMediaIds(prev => [...prev, res.data.id]);
}
// 确保 URL 是完整的
// 由于后端现在是转发到外部OSS返回的URL通常是完整的但也可能是相对的这里统一处理
let url = res.data.file;
// 处理反斜杠问题(防止 Windows 路径风格影响 URL
if (url) {
url = url.replace(/\\/g, '/');
}
if (url && !url.startsWith('http')) {
// 如果返回的是相对路径,拼接 API URL 或 Base URL
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// 移除 baseURL 末尾的 /api 或 /
const host = baseURL.replace(/\/api\/?$/, '');
// 确保 url 以 / 开头
if (!url.startsWith('/')) url = '/' + url;
url = `${host}${url}`;
}
// 清理 URL 中的双斜杠 (除协议头外)
url = url.replace(/([^:]\/)\/+/g, '$1');
// Add to media list for preview
setMediaList(prev => [...prev, {
id: res.data.id,
url: url,
type: file.type.startsWith('video') ? 'video' : 'image',
name: file.name
}]);
// 插入到编辑器
const insertText = file.type.startsWith('video')
? `\n<video src="${url}" controls width="100%"></video>\n`
: `\n![${file.name}](${url})\n`;
const newContent = content + insertText;
setContent(newContent);
form.setFieldsValue({ content: newContent });
message.success('上传成功');
} catch (error) {
console.error(error);
message.error('上传失败');
} finally {
setUploading(false);
}
return false; // 阻止默认上传行为
};
const handleSubmit = async (values) => {
setLoading(true);
try {
// 处理关联项目 ID (select value format: "type_id")
const relatedValue = values.related_item;
// Use content state instead of form value to ensure consistency
const payload = { ...values, content: content, media_ids: mediaIds };
delete payload.related_item;
if (relatedValue) {
const [type, id] = relatedValue.split('_');
if (type === 'config') payload.related_product = id;
if (type === 'course') payload.related_course = id;
if (type === 'service') payload.related_service = id;
} else {
// If cleared, set to null
payload.related_product = null;
payload.related_course = null;
payload.related_service = null;
}
if (isEditMode && topicId) {
await updateTopic(topicId, payload);
message.success('修改成功');
} else {
await createTopic(payload);
message.success('发布成功');
}
form.resetFields();
if (onSuccess) onSuccess();
onClose();
} catch (error) {
console.error(error);
message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误'));
} finally {
setLoading(false);
}
};
return (
<Modal
title={isEditMode ? "编辑帖子" : "发布新帖"}
open={visible}
onCancel={onClose}
footer={null}
destroyOnHidden
width={1000}
centered
maskClosable={false}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ category: 'discussion' }}
>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
>
<Input placeholder="请输入清晰的问题或讨论标题" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 20 }}>
<Form.Item
name="category"
label="分类"
style={{ width: 200 }}
rules={[{ required: true, message: '请选择分类' }]}
>
<Select>
<Option value="discussion">技术讨论</Option>
<Option value="help">求助问答</Option>
<Option value="share">经验分享</Option>
</Select>
</Form.Item>
<Form.Item
name="related_item"
label="关联已购项目 (可选)"
style={{ flex: 1 }}
tooltip="关联已购项目可获得“认证用户”标识"
>
<Select placeholder="选择关联项目..." allowClear>
<Select.OptGroup label="硬件产品">
{paidItems.configs.map(i => (
<Option key={`config_${i.id}`} value={`config_${i.id}`}>{i.name}</Option>
))}
</Select.OptGroup>
<Select.OptGroup label="VC 课程">
{paidItems.courses.map(i => (
<Option key={`course_${i.id}`} value={`course_${i.id}`}>{i.title}</Option>
))}
</Select.OptGroup>
<Select.OptGroup label="AI 服务">
{paidItems.services.map(i => (
<Option key={`service_${i.id}`} value={`service_${i.id}`}>{i.title}</Option>
))}
</Select.OptGroup>
</Select>
</Form.Item>
</div>
<Form.Item
name="content"
label="内容 (支持 Markdown 与 LaTeX 公式)"
rules={[{ required: true, message: '请输入内容' }]}
>
<div data-color-mode="light">
<div style={{ marginBottom: 10 }}>
<Upload
beforeUpload={handleUpload}
showUploadList={false}
accept="image/*,video/*"
>
<Button icon={<UploadOutlined />} loading={uploading} size="small">
插入图片/视频
</Button>
</Upload>
</div>
<MDEditor
value={content}
onChange={(val) => {
setContent(val);
form.setFieldsValue({ content: val });
}}
height={400}
previewOptions={{
rehypePlugins: [[rehypeKatex]],
remarkPlugins: [[remarkMath]],
}}
/>
</div>
</Form.Item>
<Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading} size="large">
{isEditMode ? "保存修改" : "立即发布"}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default CreateTopicModal;

View File

@@ -1,278 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button, Avatar, Dropdown } from 'antd';
import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined, UserOutlined, LogoutOutlined, WechatOutlined, TeamOutlined } from '@ant-design/icons';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import ParticleBackground from './ParticleBackground';
import LoginModal from './LoginModal';
import ProfileModal from './ProfileModal';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../context/AuthContext';
const { Header, Content, Footer } = AntLayout;
const Layout = ({ children }) => {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [loginVisible, setLoginVisible] = useState(false);
const [profileVisible, setProfileVisible] = useState(false);
const { user, login, logout } = useAuth();
// 全局监听并持久化 ref 参数
useEffect(() => {
const ref = searchParams.get('ref');
if (ref) {
console.log('[Layout] Capturing sales ref code:', ref);
localStorage.setItem('ref_code', ref);
}
}, [searchParams]);
const handleLogout = () => {
logout();
navigate('/');
};
const userMenu = {
items: [
{
key: 'profile',
label: '个人设置',
icon: <UserOutlined />,
onClick: () => setProfileVisible(true)
},
{
key: 'logout',
label: '退出登录',
icon: <LogoutOutlined />,
onClick: handleLogout
}
]
};
const items = [
{
key: '/',
icon: <RobotOutlined />,
label: 'AI 硬件',
},
{
key: '/forum',
icon: <TeamOutlined />,
label: '技术论坛',
},
{
key: '/services',
icon: <AppstoreOutlined />,
label: 'AI 服务',
},
{
key: '/courses',
icon: <EyeOutlined />,
label: 'VC 课程',
},
{
key: '/my-orders',
icon: <SearchOutlined />,
label: '我的订单',
},
];
const handleMenuClick = (key) => {
navigate(key);
setMobileMenuOpen(false);
};
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#00b96b',
colorBgContainer: 'transparent',
colorBgLayout: 'transparent',
fontFamily: "'Orbitron', sans-serif",
},
}}
>
<ParticleBackground />
<AntLayout style={{ minHeight: '100vh', background: 'transparent' }}>
<Header
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 1000,
width: '100%',
padding: 0,
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
display: 'flex',
height: '72px',
lineHeight: '72px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.5)'
}}
>
<div style={{
width: '100%',
padding: '0 40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '100%'
}}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
style={{
color: '#fff',
fontSize: '20px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
cursor: 'pointer'
}}
onClick={() => navigate('/')}
>
<img src="/liangji_logo.svg" alt="Quant Speed Logo" style={{ height: '40px', filter: 'invert(1) brightness(2)' }} />
</motion.div>
{/* Desktop Menu */}
<div className="desktop-menu" style={{ display: 'none', flex: 1, justifyContent: 'flex-end', alignItems: 'center' }}>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[location.pathname]}
items={items}
onClick={(e) => handleMenuClick(e.key)}
style={{
background: 'transparent',
borderBottom: 'none',
display: 'flex',
justifyContent: 'flex-end',
minWidth: '400px',
marginRight: '20px'
}}
/>
{user ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 15 }}>
{/* 小程序图标状态 */}
<WechatOutlined
style={{
fontSize: 24,
color: user.openid && !user.openid.startsWith('web_') ? '#07c160' : '#666',
cursor: 'help'
}}
title={user.openid && !user.openid.startsWith('web_') ? '已绑定微信小程序' : '未绑定微信小程序'}
/>
<Dropdown menu={userMenu}>
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: '#fff' }}>
<Avatar src={user.avatar_url} icon={<UserOutlined />} style={{ marginRight: 8 }} />
<span>{user.nickname}</span>
</div>
</Dropdown>
</div>
) : (
<Button type="primary" onClick={() => setLoginVisible(true)}>登录</Button>
)}
</div>
<style>{`
@media (min-width: 768px) {
.desktop-menu { display: flex !important; }
.mobile-menu-btn { display: none !important; }
}
`}</style>
{/* Mobile Menu Button */}
<Button
className="mobile-menu-btn"
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setMobileMenuOpen(true)}
/>
</div>
</Header>
{/* Mobile Drawer Menu */}
<Drawer
title={<span style={{ color: '#00b96b' }}>导航菜单</span>}
placement="right"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }}
>
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
{user ? (
<div style={{ color: '#fff' }}>
<Avatar
src={user.avatar_url}
icon={<UserOutlined />}
size="large"
style={{ marginBottom: 10, cursor: 'pointer' }}
onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }}
/>
<div onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}>
{user.nickname}
</div>
<Button type="link" danger onClick={handleLogout} style={{ marginTop: 10 }}>退出登录</Button>
</div>
) : (
<Button type="primary" block onClick={() => { setLoginVisible(true); setMobileMenuOpen(false); }}>登录 / 注册</Button>
)}
</div>
<Menu
theme="dark"
mode="vertical"
selectedKeys={[location.pathname]}
items={items}
onClick={(e) => handleMenuClick(e.key)}
style={{ background: 'transparent', borderRight: 'none' }}
/>
</Drawer>
<LoginModal
visible={loginVisible}
onClose={() => setLoginVisible(false)}
onLoginSuccess={(userData) => login(userData)}
/>
<ProfileModal
visible={profileVisible}
onClose={() => setProfileVisible(false)}
/>
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
minHeight: 'calc(100vh - 128px)'
}}>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20, filter: 'blur(10px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: -20, filter: 'blur(10px)' }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
</Content>
<Footer style={{ textAlign: 'center', background: 'rgba(0,0,0,0.5)', color: '#666', backdropFilter: 'blur(5px)' }}>
Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech
</Footer>
</AntLayout>
</ConfigProvider>
);
};
export default Layout;

View File

@@ -1,123 +0,0 @@
import React, { useState } from 'react';
import { Modal, Form, Input, Button, message } from 'antd';
import { UserOutlined, LockOutlined, MobileOutlined } from '@ant-design/icons';
import { sendSms, phoneLogin } from '../api';
const LoginModal = ({ visible, onClose, onLoginSuccess }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const handleSendCode = async () => {
try {
const phone = form.getFieldValue('phone_number');
if (!phone) {
message.error('请输入手机号');
return;
}
// 简单的手机号校验
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.error('请输入有效的手机号');
return;
}
//
await sendSms({ phone_number: phone });
message.success('验证码已发送');
setCountdown(60);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
} catch (error) {
console.error(error);
message.error('发送失败: ' + (error.response?.data?.error || '网络错误'));
}
};
const handleSubmit = async (values) => {
setLoading(true);
try {
const res = await phoneLogin(values);
message.success('登录成功');
onLoginSuccess(res.data);
onClose();
} catch (error) {
console.error(error);
message.error('登录失败: ' + (error.response?.data?.error || '网络错误'));
} finally {
setLoading(false);
}
};
return (
<Modal
title="用户登录 / 注册"
open={visible}
onCancel={onClose}
footer={null}
destroyOnHidden
centered
>
<Form
form={form}
name="login_form"
onFinish={handleSubmit}
layout="vertical"
style={{ marginTop: 20 }}
>
<Form.Item
name="phone_number"
rules={[{ required: true, message: '请输入手机号' }]}
>
<Input
prefix={<MobileOutlined />}
placeholder="手机号码"
size="large"
/>
</Form.Item>
<Form.Item
name="code"
rules={[{ required: true, message: '请输入验证码' }]}
>
<div style={{ display: 'flex', gap: 10 }}>
<Input
prefix={<LockOutlined />}
placeholder="验证码"
size="large"
/>
<Button
size="large"
onClick={handleSendCode}
disabled={countdown > 0}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</div>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
登录
</Button>
</Form.Item>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
未注册的手机号验证后将自动创建账号<br/>
已在小程序绑定的手机号将自动同步身份
</div>
</Form>
</Modal>
);
};
export default LoginModal;

View File

@@ -1,218 +0,0 @@
import React, { Suspense, useState, useEffect } from 'react';
import { Canvas, useLoader } from '@react-three/fiber';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei';
import { Spin } from 'antd';
import JSZip from 'jszip';
import * as THREE from 'three';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("3D Model Viewer Error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#888',
padding: 20,
textAlign: 'center',
fontSize: '14px'
}}>
3D 模型加载失败
</div>
);
}
return this.props.children;
}
}
const Model = ({ objPath, mtlPath, scale = 1 }) => {
// If mtlPath is provided, load materials first
const materials = mtlPath ? useLoader(MTLLoader, mtlPath) : null;
const obj = useLoader(OBJLoader, objPath, (loader) => {
if (materials) {
materials.preload();
loader.setMaterials(materials);
}
});
const clone = obj.clone();
return <primitive object={clone} scale={scale} />;
};
const LoadingOverlay = () => {
const { progress, active } = useProgress();
if (!active) return null;
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
zIndex: 10,
pointerEvents: 'none'
}}>
<div style={{ textAlign: 'center' }}>
<Spin size="large" />
<div style={{ color: '#00b96b', marginTop: 10, fontWeight: 'bold' }}>
{progress.toFixed(0)}% Loading
</div>
</div>
</div>
);
};
const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => {
const [paths, setPaths] = useState(null);
const [unzipping, setUnzipping] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const blobUrls = [];
const loadPaths = async () => {
if (!objPath) return;
// 如果是 zip 文件
if (objPath.toLowerCase().endsWith('.zip')) {
setUnzipping(true);
setError(null);
try {
const response = await fetch(objPath);
const arrayBuffer = await response.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
let extractedObj = null;
let extractedMtl = null;
const fileMap = {};
// 1. 提取所有文件并创建 Blob URL 映射
for (const [filename, file] of Object.entries(zip.files)) {
if (file.dir) continue;
const content = await file.async('blob');
const url = URL.createObjectURL(content);
blobUrls.push(url);
// 记录文件名到 URL 的映射,用于后续材质引用图片等情况
const baseName = filename.split('/').pop();
fileMap[baseName] = url;
if (filename.toLowerCase().endsWith('.obj')) {
extractedObj = url;
} else if (filename.toLowerCase().endsWith('.mtl')) {
extractedMtl = url;
}
}
if (isMounted) {
if (extractedObj) {
setPaths({ obj: extractedObj, mtl: extractedMtl });
} else {
setError('压缩包内未找到 .obj 模型文件');
}
}
} catch (err) {
console.error('Error unzipping model:', err);
if (isMounted) setError('加载压缩包失败');
} finally {
if (isMounted) setUnzipping(false);
}
} else {
// 普通路径
setPaths({ obj: objPath, mtl: mtlPath });
}
};
loadPaths();
return () => {
isMounted = false;
// 清理 Blob URL 释放内存
blobUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objPath, mtlPath]);
if (unzipping) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
background: 'rgba(0,0,0,0.1)'
}}>
<Spin size="large" />
<div style={{ color: '#00b96b', marginTop: 15, fontWeight: '500' }}>正在解压 3D 资源...</div>
</div>
);
}
if (error) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#ff4d4f',
padding: 20,
textAlign: 'center'
}}>
{error}
</div>
);
}
if (!paths) return null;
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<ErrorBoundary>
<LoadingOverlay />
<Canvas shadows dpr={[1, 2]} camera={{ fov: 45, position: [0, 0, 5] }} style={{ height: '100%', width: '100%' }}>
<ambientLight intensity={0.7} />
<pointLight position={[10, 10, 10]} intensity={1} />
<spotLight position={[-10, 10, 10]} angle={0.15} penumbra={1} intensity={1} />
<Suspense fallback={null}>
<Stage environment="city" intensity={0.6} adjustCamera={true}>
<Model objPath={paths.obj} mtlPath={paths.mtl} scale={scale} />
</Stage>
<Environment preset="city" />
<ContactShadows position={[0, -0.8, 0]} opacity={0.4} scale={10} blur={2} far={0.8} />
</Suspense>
<OrbitControls autoRotate={autoRotate} makeDefault />
</Canvas>
</ErrorBoundary>
</div>
);
};
export default ModelViewer;

View File

@@ -1,174 +0,0 @@
import React, { useEffect, useRef } from 'react';
const ParticleBackground = () => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let animationFrameId;
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const particles = [];
const particleCount = 100;
const meteors = [];
const meteorCount = 8;
class Particle {
constructor() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.vx = (Math.random() - 0.5) * 0.5;
this.vy = (Math.random() - 0.5) * 0.5;
this.size = Math.random() * 2;
this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '; // Green or Blue
}
update() {
this.x += this.vx;
this.y += this.vy;
if (this.x < 0 || this.x > canvas.width) this.vx *= -1;
if (this.y < 0 || this.y > canvas.height) this.vy *= -1;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = this.color + Math.random() * 0.5 + ')';
ctx.fill();
}
}
class Meteor {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * canvas.width * 1.5; // Start further right
this.y = Math.random() * -canvas.height; // Start further above
this.vx = -(Math.random() * 5 + 5); // Faster
this.vy = Math.random() * 5 + 5; // Faster
this.len = Math.random() * 150 + 150; // Longer trail
this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, ';
this.opacity = 0;
this.maxOpacity = Math.random() * 0.5 + 0.2;
this.wait = Math.random() * 300; // Random delay before showing up
}
update() {
if (this.wait > 0) {
this.wait--;
return;
}
this.x += this.vx;
this.y += this.vy;
if (this.opacity < this.maxOpacity) {
this.opacity += 0.02;
}
if (this.x < -this.len || this.y > canvas.height + this.len) {
this.reset();
}
}
draw() {
if (this.wait > 0) return;
const tailX = this.x - this.vx * (this.len / 15);
const tailY = this.y - this.vy * (this.len / 15);
const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY);
gradient.addColorStop(0, this.color + this.opacity + ')');
gradient.addColorStop(0.1, this.color + (this.opacity * 0.5) + ')');
gradient.addColorStop(1, this.color + '0)');
ctx.save();
// Add glow effect
ctx.shadowBlur = 8;
ctx.shadowColor = this.color.replace('rgba', 'rgb').replace(', ', ')');
ctx.beginPath();
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.moveTo(this.x, this.y);
ctx.lineTo(tailX, tailY);
ctx.stroke();
// Add a bright head
ctx.beginPath();
ctx.fillStyle = '#fff';
ctx.arc(this.x, this.y, 1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
for (let i = 0; i < meteorCount; i++) {
meteors.push(new Meteor());
}
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw meteors first (in background)
meteors.forEach(m => {
m.update();
m.draw();
});
// Draw connecting lines
ctx.lineWidth = 0.5;
for (let i = 0; i < particleCount; i++) {
for (let j = i; j < particleCount; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 100) {
ctx.beginPath();
ctx.strokeStyle = `rgba(100, 255, 218, ${1 - distance / 100})`;
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
particles.forEach(p => {
p.update();
p.draw();
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
return () => {
window.removeEventListener('resize', resizeCanvas);
cancelAnimationFrame(animationFrameId);
};
}, []);
return <canvas ref={canvasRef} id="particle-canvas" />;
};
export default ParticleBackground;

View File

@@ -1,124 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Upload, Button, message, Avatar } from 'antd';
import { UserOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
import { useAuth } from '../context/AuthContext';
import { updateUserInfo, uploadUserAvatar } from '../api';
const ProfileModal = ({ visible, onClose }) => {
const { user, updateUser } = useAuth();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState('');
useEffect(() => {
if (visible && user) {
form.setFieldsValue({
nickname: user.nickname,
});
setAvatarUrl(user.avatar_url);
}
}, [visible, user, form]);
const handleUpload = async (file) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('You can only upload JPG/PNG file!');
return Upload.LIST_IGNORE;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('Image must smaller than 2MB!');
return Upload.LIST_IGNORE;
}
const formData = new FormData();
formData.append('file', file);
setUploading(true);
try {
const res = await uploadUserAvatar(formData);
if (res.data.success) {
setAvatarUrl(res.data.file_url);
message.success('头像上传成功');
} else {
message.error('头像上传失败: ' + (res.data.message || '未知错误'));
}
} catch (error) {
console.error('Upload failed:', error);
message.error('头像上传失败');
} finally {
setUploading(false);
}
return false; // Prevent default auto upload
};
const handleOk = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const updateData = {
nickname: values.nickname,
avatar_url: avatarUrl
};
const res = await updateUserInfo(updateData);
updateUser(res.data);
message.success('个人信息更新成功');
onClose();
} catch (error) {
console.error('Update failed:', error);
message.error('更新失败');
} finally {
setLoading(false);
}
};
return (
<Modal
title="个人设置"
open={visible}
onOk={handleOk}
onCancel={onClose}
confirmLoading={loading}
centered
>
<Form
form={form}
layout="vertical"
style={{ marginTop: 20 }}
>
<Form.Item label="头像" style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 15 }}>
<Avatar
size={100}
src={avatarUrl}
icon={<UserOutlined />}
/>
<Upload
name="avatar"
showUploadList={false}
beforeUpload={handleUpload}
accept="image/*"
>
<Button icon={uploading ? <LoadingOutlined /> : <UploadOutlined />} loading={uploading}>
{uploading ? '上传中...' : '更换头像'}
</Button>
</Upload>
</div>
</Form.Item>
<Form.Item
name="nickname"
label="昵称"
rules={[{ required: true, message: '请输入昵称' }]}
>
<Input placeholder="请输入昵称" maxLength={20} />
</Form.Item>
</Form>
</Modal>
);
};
export default ProfileModal;

View File

@@ -1,101 +0,0 @@
import React, { useState, useRef, useLayoutEffect } from 'react';
import { motion } from 'framer-motion';
import { CalendarOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import styles from './activity.module.less';
import { hoverScale } from '../../animation';
const ActivityCard = ({ activity }) => {
const navigate = useNavigate();
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const imgRef = useRef(null);
const handleCardClick = () => {
navigate(`/activity/${activity.id}`);
};
const getStatus = (startTime) => {
const now = new Date();
const start = new Date(startTime);
if (now < start) return '即将开始';
return '报名中';
};
const formatDate = (dateStr) => {
if (!dateStr) return 'TBD';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
};
const imgSrc = hasError
? 'https://via.placeholder.com/600x400?text=No+Image'
: (activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://via.placeholder.com/600x400');
// Check if image is already loaded (cached) to prevent flashing
useLayoutEffect(() => {
if (imgRef.current && imgRef.current.complete) {
setIsLoaded(true);
}
}, [imgSrc]);
return (
<motion.div
className={styles.activityCard}
variants={hoverScale}
whileHover="hover"
onClick={handleCardClick}
layoutId={`activity-card-${activity.id}`}
style={{ willChange: 'transform' }}
>
<div className={styles.imageContainer}>
{/* Placeholder Background - Always visible behind the image */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#f5f5f5',
zIndex: 0,
}}
/>
<img
ref={imgRef}
src={imgSrc}
alt={activity.title}
style={{
position: 'relative',
zIndex: 1,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-out'
}}
onLoad={() => setIsLoaded(true)}
onError={() => {
setHasError(true);
setIsLoaded(true);
}}
loading="lazy"
/>
<div className={styles.overlay} style={{ zIndex: 2 }}>
<div className={styles.statusTag}>
{activity.status || getStatus(activity.start_time)}
</div>
<h3 className={styles.title}>{activity.title}</h3>
<div className={styles.time}>
<CalendarOutlined />
<span>{formatDate(activity.start_time)}</span>
</div>
</div>
</div>
</motion.div>
);
};
export default ActivityCard;

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import ActivityCard from './ActivityCard';
import '../../index.css'; // Global styles
import '../../App.css';
export default {
title: 'Components/Activity/ActivityCard',
component: ActivityCard,
decorators: [
(Story) => (
<MemoryRouter>
<div style={{ maxWidth: '400px', padding: '20px' }}>
<Story />
</div>
</MemoryRouter>
),
],
tags: ['autodocs'],
};
const Template = (args) => <ActivityCard {...args} />;
export const NotStarted = Template.bind({});
NotStarted.args = {
activity: {
id: 1,
title: 'Future AI Hardware Summit 2026',
start_time: '2026-12-01T09:00:00',
status: '即将开始',
cover_image: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?auto=format&fit=crop&q=80',
},
};
export const Ongoing = Template.bind({});
Ongoing.args = {
activity: {
id: 2,
title: 'Edge Computing Hackathon',
start_time: '2025-10-20T10:00:00',
status: '报名中',
cover_image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&q=80',
},
};
export const Ended = Template.bind({});
Ended.args = {
activity: {
id: 3,
title: 'Deep Learning Workshop',
start_time: '2023-05-15T14:00:00',
status: '已结束',
cover_image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80',
},
};
export const SignedUp = Template.bind({});
SignedUp.args = {
activity: {
id: 4,
title: 'Exclusive Developer Meetup',
start_time: '2025-11-11T18:00:00',
status: '已报名',
cover_image: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80',
},
};

View File

@@ -1,110 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { RightOutlined, LeftOutlined } from '@ant-design/icons';
import { getActivities } from '../../api';
import ActivityCard from './ActivityCard';
import styles from './activity.module.less';
import { fadeInUp, staggerContainer } from '../../animation';
const ActivityList = () => {
const { data: activities, isLoading, error } = useQuery({
queryKey: ['activities'],
queryFn: async () => {
const res = await getActivities();
// Handle different response structures
return Array.isArray(res.data) ? res.data : (res.data?.results || []);
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
});
const [currentIndex, setCurrentIndex] = useState(0);
// Auto-play for desktop carousel
useEffect(() => {
if (!activities || activities.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % activities.length);
}, 5000);
return () => clearInterval(interval);
}, [activities]);
const nextSlide = () => {
if (!activities) return;
setCurrentIndex((prev) => (prev + 1) % activities.length);
};
const prevSlide = () => {
if (!activities) return;
setCurrentIndex((prev) => (prev - 1 + activities.length) % activities.length);
};
if (isLoading) return <div className={styles.loading}>Loading activities...</div>;
if (error) return null; // Or error state
if (!activities || activities.length === 0) return null;
return (
<motion.div
className={styles.activitySection}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={staggerContainer}
>
<div className={styles.header}>
<h2 className={styles.sectionTitle}>
近期活动 / EVENTS
</h2>
<div className={styles.controls}>
<button onClick={prevSlide} className={styles.navBtn}><LeftOutlined /></button>
<button onClick={nextSlide} className={styles.navBtn}><RightOutlined /></button>
</div>
</div>
{/* Desktop: Carousel (Show one prominent, but allows list structure if needed)
User said: "Activity only shows one, and in the form of a sliding page"
*/}
<div className={styles.desktopCarousel}>
<AnimatePresence>
<motion.div
key={currentIndex}
initial={{ x: '100%' }}
animate={{ x: 0, zIndex: 1 }}
exit={{ x: '-100%', zIndex: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={{
width: '100%',
position: 'absolute',
top: 0,
left: 0,
}}
>
<ActivityCard activity={activities[currentIndex]} />
</motion.div>
</AnimatePresence>
<div className={styles.dots} style={{ position: 'absolute', bottom: '10px', width: '100%', zIndex: 10 }}>
{activities.map((_, idx) => (
<span
key={idx}
className={`${styles.dot} ${idx === currentIndex ? styles.activeDot : ''}`}
onClick={() => setCurrentIndex(idx)}
/>
))}
</div>
</div>
{/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */}
<div className={styles.mobileList}>
{activities.map((item, index) => (
<motion.div key={item.id} variants={fadeInUp} custom={index}>
<ActivityCard activity={item} />
</motion.div>
))}
</div>
</motion.div>
);
};
export default ActivityList;

View File

@@ -1,266 +0,0 @@
@import '../../theme.module.less';
.activitySection {
padding: var(--spacing-lg) 0;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.sectionTitle {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
display: block;
width: 4px;
height: 24px;
background: var(--primary-color);
border-radius: 2px;
}
}
.controls {
display: flex;
gap: var(--spacing-sm);
@media (max-width: 768px) {
display: none; /* Hide carousel controls on mobile */
}
}
.navBtn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary);
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: var(--primary-color);
border-color: var(--primary-color);
}
}
/* Desktop Carousel */
.desktopCarousel {
position: relative;
width: 100%;
height: 440px; /* 400px card + space for dots */
overflow: hidden;
@media (max-width: 768px) {
display: none;
}
}
.dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: var(--spacing-md);
}
.dot {
width: 8px;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
cursor: pointer;
transition: all 0.3s;
&.activeDot {
background: var(--primary-color);
transform: scale(1.2);
}
}
/* Mobile List */
.mobileList {
display: none;
flex-direction: column;
gap: var(--spacing-md);
@media (max-width: 768px) {
display: flex;
}
}
/* --- Card Styles --- */
.activityCard {
position: relative;
width: 100%;
height: 400px;
border-radius: var(--border-radius-lg);
overflow: hidden;
cursor: pointer;
background: var(--background-card);
box-shadow: var(--box-shadow-base);
transition: all 0.3s ease;
@media (max-width: 768px) {
height: 300px;
}
}
.imageContainer {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
}
.overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60%;
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--spacing-lg);
box-sizing: border-box;
}
.statusTag {
display: inline-block;
background: var(--primary-color);
color: #fff;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-bottom: var(--spacing-sm);
width: fit-content;
text-transform: uppercase;
}
.title {
color: var(--text-primary);
font-size: 24px;
font-weight: 700;
margin-bottom: var(--spacing-xs);
line-height: 1.3;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
@media (max-width: 768px) {
font-size: 18px;
}
}
.time {
color: var(--text-secondary);
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
/* Detail Page Styles */
.detailHeader {
position: relative;
height: 50vh;
min-height: 300px;
width: 100%;
overflow: hidden;
}
.detailImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.detailContent {
max-width: 800px;
margin: -60px auto 0;
position: relative;
z-index: 10;
padding: 0 var(--spacing-lg) 100px; /* Bottom padding for fixed footer */
}
.infoCard {
background: var(--background-card);
padding: var(--spacing-lg);
border-radius: var(--border-radius-lg);
box-shadow: var(--box-shadow-base);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--border-color);
}
.richText {
color: var(--text-secondary);
line-height: 1.8;
font-size: 16px;
img {
max-width: 100%;
border-radius: var(--border-radius-base);
margin: var(--spacing-md) 0;
}
h1, h2, h3 {
color: var(--text-primary);
margin-top: var(--spacing-lg);
}
}
.fixedFooter {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(31, 31, 31, 0.95);
backdrop-filter: blur(10px);
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
box-shadow: 0 -4px 12px rgba(0,0,0,0.2);
}
.actionBtn {
background: var(--primary-color);
color: #fff;
border: none;
padding: 12px 32px;
border-radius: 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3);
transition: all 0.3s;
&:disabled {
background: #555;
cursor: not-allowed;
box-shadow: none;
}
}

View File

@@ -1,81 +0,0 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import { getUserInfo } from '../api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const initAuth = async () => {
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken) {
try {
// 1. 优先尝试从本地获取
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
// 如果本地数据包含 ID直接使用
if (parsedUser.id) {
setUser(parsedUser);
} else {
// 如果没有 ID标记为需要刷新
throw new Error("Missing ID in stored user");
}
} catch (e) {
// 解析失败或数据不完整,继续从服务器获取
}
}
// 2. 总是尝试从服务器获取最新信息(或作为兜底)
// 这样可以确保 ID 存在,且信息是最新的
const res = await getUserInfo();
if (res.data) {
setUser(res.data);
localStorage.setItem('user', JSON.stringify(res.data));
}
} catch (error) {
console.error("Failed to fetch user info:", error);
// 如果 token 失效,可能需要登出?
// 暂时不强制登出,只清除无效的本地 user
if (!user) localStorage.removeItem('user');
}
}
setLoading(false);
};
initAuth();
}, []);
const login = (userData) => {
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
if (userData.token) {
localStorage.setItem('token', userData.token);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
};
const updateUser = (data) => {
const newUser = { ...user, ...data };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
};
return (
<AuthContext.Provider value={{ user, login, logout, updateUser, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

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

View File

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

View File

@@ -1,235 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Typography, Button, Spin } from 'antd';
import { motion } from 'framer-motion';
import {
RightOutlined,
SearchOutlined,
DatabaseOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
CloudServerOutlined
} from '@ant-design/icons';
import { getServices } from '../api';
import { useNavigate } from 'react-router-dom';
const { Title, Paragraph } = Typography;
const AIServices = () => {
const [services, setServices] = useState([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
const fetchServices = async () => {
try {
const response = await getServices();
setServices(response.data);
} catch (error) {
console.error("Failed to fetch services:", error);
} finally {
setLoading(false);
}
};
fetchServices();
}, []);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 20 }}>Loading services...</div>
</div>
);
}
return (
<div style={{ padding: '20px 0' }}>
<div style={{ textAlign: 'center', marginBottom: 60 }}>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8 }}
>
<Title level={1} style={{ color: '#fff', fontSize: 'clamp(2rem, 4vw, 3rem)' }}>
AI 全栈<span style={{ color: '#00f0ff', textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>解决方案</span>
</Title>
</motion.div>
<Paragraph style={{ color: '#888', maxWidth: 700, margin: '0 auto', fontSize: 16 }}>
从数据处理到模型部署我们为您提供一站式 AI 基础设施服务
</Paragraph>
</div>
<Row gutter={[32, 32]} justify="center">
{services.map((item, index) => (
<Col xs={24} md={8} key={item.id}>
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.2, duration: 0.5 }}
whileHover={{ scale: 1.03 }}
onClick={() => navigate(`/services/${item.id}`)}
style={{ cursor: 'pointer' }}
>
<div
className="glass-panel"
style={{
padding: 30,
height: '100%',
position: 'relative',
overflow: 'hidden',
border: `1px solid ${item.color}33`,
boxShadow: `0 0 20px ${item.color}11`
}}
>
{/* HUD 装饰线 */}
<div style={{ position: 'absolute', top: 0, left: 0, width: 20, height: 2, background: item.color }} />
<div style={{ position: 'absolute', top: 0, left: 0, width: 2, height: 20, background: item.color }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 20, height: 2, background: item.color }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 2, height: 20, background: item.color }} />
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center' }}>
<div style={{
width: 60, height: 60,
borderRadius: '50%',
background: `${item.color}22`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 15,
overflow: 'hidden'
}}>
{item.display_icon ? (
<img src={item.display_icon} alt={item.title} style={{ width: '60%', height: '60%', objectFit: 'contain' }} />
) : (
<div style={{ width: 30, height: 30, background: item.color, borderRadius: '50%' }} />
)}
</div>
<h3 style={{ margin: 0, fontSize: 22, color: '#fff' }}>{item.title}</h3>
</div>
<p style={{ color: '#ccc', lineHeight: 1.6, minHeight: 60 }}>{item.description}</p>
<div style={{ marginTop: 20 }}>
{item.features_list && item.features_list.map((feat, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', marginBottom: 8, color: item.color
}}>
<div style={{ width: 6, height: 6, background: item.color, marginRight: 10, borderRadius: '50%' }} />
{feat}
</div>
))}
</div>
<Button
type="link"
style={{ padding: 0, marginTop: 20, color: '#fff' }}
icon={<RightOutlined />}
onClick={(e) => {
e.stopPropagation();
navigate(`/services/${item.id}`);
}}
>
了解更多
</Button>
</div>
</motion.div>
</Col>
))}
</Row>
{/* 动态流程图优化 */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1 }}
style={{
marginTop: 100,
padding: '60px 20px',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,185,107,0.05) 100%)',
borderRadius: 30,
border: '1px solid rgba(255,255,255,0.05)',
position: 'relative',
overflow: 'hidden'
}}
>
<div style={{ position: 'absolute', top: -50, right: -50, width: 200, height: 200, background: 'radial-gradient(circle, rgba(0,240,255,0.1) 0%, transparent 70%)', filter: 'blur(30px)' }} />
<Title level={2} style={{ color: '#fff', marginBottom: 60, textAlign: 'center' }}>
<span className="neon-text-green">服务流程</span>
</Title>
<Row justify="center" gutter={[0, 40]} style={{ position: 'relative' }}>
{[
{ title: '需求分析', icon: <SearchOutlined />, desc: '深度沟通需求' },
{ title: '数据准备', icon: <DatabaseOutlined />, desc: '高效数据处理' },
{ title: '模型训练', icon: <ThunderboltOutlined />, desc: '高性能算力' },
{ title: '测试验证', icon: <CheckCircleOutlined />, desc: '多维精度测试' },
{ title: '私有化部署', icon: <CloudServerOutlined />, desc: '全栈落地部署' }
].map((step, i) => (
<Col key={i} xs={24} sm={12} md={4}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
<motion.div
initial={{ scale: 0, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
transition={{ delay: i * 0.2, type: 'spring', stiffness: 100 }}
whileHover={{ y: -10 }}
style={{
width: 80,
height: 80,
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(0, 185, 107, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 32,
color: '#00b96b',
marginBottom: 20,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
backdropFilter: 'blur(10px)',
zIndex: 2
}}
>
{step.icon}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.2 + 0.3 }}
>
<div style={{ color: '#fff', fontSize: 18, fontWeight: 'bold', marginBottom: 8 }}>{step.title}</div>
<div style={{ color: '#666', fontSize: 12 }}>{step.desc}</div>
</motion.div>
{/* 连接线 */}
{i < 4 && (
<div className="process-line" style={{
position: 'absolute',
top: 40,
right: '-50%',
width: '100%',
height: '2px',
background: 'linear-gradient(90deg, #00b96b33, #00b96b00)',
zIndex: 1,
display: 'none'
}} />
)}
</div>
</Col>
))}
</Row>
<style>{`
@media (min-width: 768px) {
.process-line { display: block !important; }
}
.neon-text-green {
text-shadow: 0 0 10px rgba(0, 185, 107, 0.5);
}
`}</style>
</motion.div>
</div>
);
};
export default AIServices;

View File

@@ -1,372 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip } from 'antd';
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined } from '@ant-design/icons';
import { getTopicDetail, createReply, uploadMedia } from '../api';
import { useAuth } from '../context/AuthContext';
import LoginModal from '../components/LoginModal';
import CreateTopicModal from '../components/CreateTopicModal';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import 'katex/dist/katex.min.css';
const { Title, Text } = Typography;
const { TextArea } = Input;
const ForumDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [topic, setTopic] = useState(null);
const [replyContent, setReplyContent] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loginModalVisible, setLoginModalVisible] = useState(false);
// Edit Topic State
const [editModalVisible, setEditModalVisible] = useState(false);
// Reply Image State
const [replyUploading, setReplyUploading] = useState(false);
const [replyMediaIds, setReplyMediaIds] = useState([]);
const fetchTopic = async () => {
try {
const res = await getTopicDetail(id);
setTopic(res.data);
} catch (error) {
console.error(error);
message.error('加载失败');
} finally {
setLoading(false);
}
};
const hasFetched = React.useRef(false);
useEffect(() => {
if (!hasFetched.current) {
fetchTopic();
hasFetched.current = true;
}
}, [id]);
const handleSubmitReply = async () => {
if (!user) {
setLoginModalVisible(true);
return;
}
if (!replyContent.trim()) {
message.warning('请输入回复内容');
return;
}
setSubmitting(true);
try {
await createReply({
topic: id,
content: replyContent,
media_ids: replyMediaIds // Send uploaded media IDs
});
message.success('回复成功');
setReplyContent('');
setReplyMediaIds([]); // Reset media IDs
fetchTopic(); // Refresh to show new reply
} catch (error) {
console.error(error);
message.error('回复失败');
} finally {
setSubmitting(false);
}
};
const handleReplyUpload = async (file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image');
setReplyUploading(true);
try {
const res = await uploadMedia(formData);
if (res.data.id) {
setReplyMediaIds(prev => [...prev, res.data.id]);
}
let url = res.data.file;
if (url) url = url.replace(/\\/g, '/');
if (url && !url.startsWith('http')) {
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const host = baseURL.replace(/\/api\/?$/, '');
if (!url.startsWith('/')) url = '/' + url;
url = `${host}${url}`;
}
url = url.replace(/([^:]\/)\/+/g, '$1');
const insertText = file.type.startsWith('video')
? `\n<video src="${url}" controls width="100%"></video>\n`
: `\n![${file.name}](${url})\n`;
setReplyContent(prev => prev + insertText);
message.success('上传成功');
} catch (error) {
console.error(error);
message.error('上传失败');
} finally {
setReplyUploading(false);
}
return false;
};
if (loading) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Loading...</div>;
if (!topic) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Topic not found</div>;
const markdownComponents = {
// eslint-disable-next-line no-unused-vars
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
// eslint-disable-next-line no-unused-vars
img({node, ...props}) {
return (
<img
{...props}
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%', margin: '10px 0' }}
/>
);
}
};
return (
<div style={{ padding: '80px 20px 40px', minHeight: '100vh', maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Button
type="text"
icon={<LeftOutlined />}
style={{ color: '#fff' }}
onClick={() => navigate('/forum')}
>
返回列表
</Button>
{/* Debug Info: Remove in production */}
{/* <div style={{ color: 'red', fontSize: 10 }}>
User ID: {user?.id} ({typeof user?.id})<br/>
Topic Author: {topic.author} ({typeof topic.author})<br/>
Match: {String(topic.author) === String(user?.id) ? 'Yes' : 'No'}
</div> */}
{user && String(topic.author) === String(user.id) && (
<Button
type="primary"
ghost
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
编辑帖子
</Button>
)}
</div>
{/* Topic Content */}
<Card
style={{
background: 'rgba(20,20,20,0.8)',
border: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
marginBottom: 30
}}
styles={{ body: { padding: '30px' } }}
>
<div style={{ marginBottom: 20 }}>
{topic.is_pinned && <Tag color="red" style={{ marginRight: 10 }}>置顶</Tag>}
{topic.product_info && <Tag color="blue">{topic.product_info.name}</Tag>}
<Title level={2} style={{ color: '#fff', margin: '10px 0' }}>{topic.title}</Title>
<Space size="large" style={{ color: '#888', marginTop: 10 }}>
<Space>
<Avatar src={topic.author_info?.avatar_url} icon={<UserOutlined />} />
<span style={{ color: '#ccc' }}>{topic.author_info?.nickname}</span>
{topic.is_verified_owner && (
<Tooltip title="已验证购买过相关产品">
<CheckCircleFilled style={{ color: '#00b96b' }} />
</Tooltip>
)}
</Space>
<Space>
<ClockCircleOutlined />
<span>{new Date(topic.created_at).toLocaleString()}</span>
</Space>
<Space>
<EyeOutlined />
<span>{topic.view_count} 阅读</span>
</Space>
</Space>
</div>
<Divider style={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<div style={{
color: '#ddd',
fontSize: 16,
lineHeight: 1.8,
minHeight: 200,
}} className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={markdownComponents}
>
{topic.content}
</ReactMarkdown>
</div>
{(() => {
if (topic.media && topic.media.length > 0) {
return topic.media.filter(m => m.media_type === 'video').map((media) => (
<div key={`media-${media.id}`} style={{ marginTop: 12 }}>
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} />
</div>
));
}
return null;
})()}
</Card>
{/* Replies List */}
<div style={{ marginBottom: 30 }}>
<Title level={4} style={{ color: '#fff', marginBottom: 20 }}>
{topic.replies?.length || 0} 条回复
</Title>
{topic.replies?.map((reply, index) => (
<Card
key={reply.id}
style={{
background: 'rgba(255,255,255,0.05)',
border: 'none',
marginBottom: 16,
borderRadius: 8
}}
>
<div style={{ display: 'flex', gap: 16 }}>
<Avatar src={reply.author_info?.avatar_url} icon={<UserOutlined />} />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Space>
<Text style={{ color: '#aaa', fontWeight: 'bold' }}>{reply.author_info?.nickname}</Text>
<Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text>
</Space>
<Text style={{ color: '#444' }}>#{index + 1}</Text>
</div>
<div style={{ color: '#eee' }}>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={markdownComponents}
>
{reply.content}
</ReactMarkdown>
</div>
</div>
</div>
</Card>
))}
</div>
{/* Reply Form */}
<Card
style={{
background: 'rgba(20,20,20,0.8)',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<Title level={5} style={{ color: '#fff', marginBottom: 16 }}>发表回复</Title>
{user ? (
<>
<TextArea
rows={4}
value={replyContent}
onChange={e => setReplyContent(e.target.value)}
placeholder="友善回复,分享你的见解... (支持 Markdown)"
style={{ marginBottom: 16, background: '#111', border: '1px solid #333', color: '#fff' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Upload
beforeUpload={handleReplyUpload}
showUploadList={false}
accept="image/*,video/*"
>
<Button
icon={<UploadOutlined />}
loading={replyUploading}
style={{
color: '#fff',
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.2)'
}}
>
插入图片/视频
</Button>
</Upload>
<Button type="primary" onClick={handleSubmitReply} loading={submitting}>
提交回复
</Button>
</div>
</>
) : (
<div style={{ textAlign: 'center', padding: 20 }}>
<Text style={{ color: '#888' }}>登录后参与讨论</Text>
<br/>
<Button type="primary" style={{ marginTop: 10 }} onClick={() => setLoginModalVisible(true)}>
立即登录
</Button>
</div>
)}
</Card>
<LoginModal
visible={loginModalVisible}
onClose={() => setLoginModalVisible(false)}
onLoginSuccess={() => {}}
/>
{/* Edit Modal */}
<CreateTopicModal
visible={editModalVisible}
onClose={() => {
setEditModalVisible(false);
// Workaround for scroll issue: Force reload page on close
window.location.reload();
}}
onSuccess={() => {
fetchTopic();
}}
initialValues={topic}
isEditMode={true}
topicId={topic?.id}
/>
</div>
);
};
export default ForumDetail;

View File

@@ -1,328 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip, Tabs, Row, Col } from 'antd';
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { getTopics, getStarUsers, getAnnouncements } from '../api';
import { useAuth } from '../context/AuthContext';
import CreateTopicModal from '../components/CreateTopicModal';
import LoginModal from '../components/LoginModal';
const { Title, Text, Paragraph } = Typography;
const ForumList = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [topics, setTopics] = useState([]);
const [starUsers, setStarUsers] = useState([]);
const [announcements, setAnnouncements] = useState([]);
const [searchText, setSearchText] = useState('');
const [category, setCategory] = useState('all');
const [createModalVisible, setCreateModalVisible] = useState(false);
const [loginModalVisible, setLoginModalVisible] = useState(false);
const fetchTopics = async (search = '', cat = '') => {
setLoading(true);
try {
const params = {};
if (search) params.search = search;
if (cat && cat !== 'all') params.category = cat;
const res = await getTopics(params);
setTopics(res.data.results || res.data); // Support pagination result or list
} catch (error) {
console.error(error);
message.error('获取帖子列表失败');
} finally {
setLoading(false);
}
};
const fetchStarUsers = async () => {
try {
const res = await getStarUsers();
setStarUsers(res.data);
} catch (error) {
console.error("Fetch star users failed", error);
}
};
const fetchAnnouncements = async () => {
try {
const res = await getAnnouncements();
setAnnouncements(res.data.results || res.data);
} catch (error) {
console.error("Fetch announcements failed", error);
}
};
useEffect(() => {
fetchTopics(searchText, category);
fetchStarUsers();
fetchAnnouncements();
}, [category]);
const handleSearch = (value) => {
setSearchText(value);
fetchTopics(value, category);
};
const handleCreateClick = () => {
if (!user) {
setLoginModalVisible(true);
return;
}
setCreateModalVisible(true);
};
const getCategoryIcon = (cat) => {
switch(cat) {
case 'help': return <QuestionCircleOutlined style={{ color: '#faad14' }} />;
case 'share': return <ShareAltOutlined style={{ color: '#1890ff' }} />;
case 'notice': return <SoundOutlined style={{ color: '#ff4d4f' }} />;
default: return <MessageOutlined style={{ color: '#00b96b' }} />;
}
};
const getCategoryLabel = (cat) => {
switch(cat) {
case 'help': return '求助';
case 'share': return '分享';
case 'notice': return '公告';
default: return '讨论';
}
};
const items = [
{ key: 'all', label: '全部话题' },
{ key: 'discussion', label: '技术讨论' },
{ key: 'help', label: '求助问答' },
{ key: 'share', label: '经验分享' },
{ key: 'notice', label: '官方公告' },
];
return (
<div style={{ minHeight: '100vh', paddingBottom: 60 }}>
{/* Hero Section */}
<div style={{
textAlign: 'center',
padding: '80px 20px 40px',
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,185,107,0.1) 100%)'
}}>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Title level={1} style={{ color: '#fff', fontFamily: "'Orbitron', sans-serif", marginBottom: 10 }}>
<span style={{ color: '#00b96b' }}>Quant Speed</span> Developer Community
</Title>
<Text style={{ color: '#888', fontSize: 18, maxWidth: 600, display: 'block', margin: '0 auto 30px' }}>
技术交流 · 硬件开发 · 官方支持 · 量迹生态
</Text>
</motion.div>
<div style={{ maxWidth: 600, margin: '0 auto', display: 'flex', gap: 10 }}>
<Input
size="large"
placeholder="搜索感兴趣的话题..."
prefix={<SearchOutlined style={{ color: '#666' }} />}
style={{ borderRadius: 8, background: 'rgba(255,255,255,0.1)', border: '1px solid #333', color: '#fff' }}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={(e) => handleSearch(e.target.value)}
/>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={handleCreateClick}
style={{ height: 'auto', borderRadius: 8 }}
>
发布新帖
</Button>
</div>
</div>
{/* Content Section */}
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 20px' }}>
<Row gutter={24}>
<Col xs={24} md={18}>
<Tabs
defaultActiveKey="all"
items={items}
onChange={setCategory}
tabBarStyle={{ color: '#fff' }}
/>
<List
loading={loading}
itemLayout="vertical"
dataSource={topics}
renderItem={(item) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<Card
hoverable
style={{
marginBottom: 16,
background: 'rgba(20,20,20,0.6)',
border: item.is_pinned ? '1px solid rgba(0, 185, 107, 0.4)' : '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
boxShadow: item.is_pinned ? '0 0 10px rgba(0, 185, 107, 0.1)' : 'none'
}}
bodyStyle={{ padding: '20px 24px' }}
onClick={() => navigate(`/forum/${item.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{item.is_pinned && <Tag color="red" icon={<FireOutlined />}>置顶</Tag>}
<Tag icon={getCategoryIcon(item.category)} style={{ background: 'transparent', color: '#fff', border: '1px solid #444' }}>
{getCategoryLabel(item.category)}
</Tag>
{item.is_verified_owner && (
<Tooltip title="已验证购买过相关产品">
<Tag icon={<CheckCircleFilled />} color="#00b96b" style={{ margin: 0 }}>认证用户</Tag>
</Tooltip>
)}
<Text style={{ color: '#fff', fontSize: 18, fontWeight: 'bold', cursor: 'pointer' }}>
{item.title}
</Text>
</div>
<Paragraph
ellipsis={{ rows: 2 }}
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
>
{item.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')} {/* Simple markdown strip */}
</Paragraph>
{item.content.match(/!\[.*?\]\((.*?)\)/) && (
<div style={{ marginBottom: 12 }}>
<img
src={item.content.match(/!\[.*?\]\((.*?)\)/)[1]}
alt="cover"
style={{ maxHeight: 150, borderRadius: 8, maxWidth: '100%' }}
/>
</div>
)}
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
<Space>
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
<Text style={{ color: item.author_info?.is_star ? '#ffd700' : '#888', fontWeight: item.author_info?.is_star ? 'bold' : 'normal' }}>
{item.author_info?.nickname || '匿名用户'}
</Text>
{item.author_info?.is_star && (
<Tooltip title={item.author_info.title || "技术专家"}>
<StarFilled style={{ color: '#ffd700' }} />
</Tooltip>
)}
</Space>
<span></span>
<span>{new Date(item.created_at).toLocaleDateString()}</span>
{item.product_info && (
<Tag color="blue" style={{ marginLeft: 8 }}>{item.product_info.name}</Tag>
)}
</Space>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, minWidth: 80 }}>
<div style={{ textAlign: 'center' }}>
<MessageOutlined style={{ fontSize: 16, color: '#00b96b' }} />
<div style={{ color: '#fff', fontWeight: 'bold' }}>{item.replies?.length || 0}</div>
</div>
<div style={{ textAlign: 'center', marginTop: 5 }}>
<EyeOutlined style={{ fontSize: 16, color: '#666' }} />
<div style={{ color: '#888', fontSize: 12 }}>{item.view_count || 0}</div>
</div>
</div>
</div>
</Card>
</motion.div>
)}
locale={{ emptyText: <div style={{ color: '#666', padding: 40 }}>暂无帖子来发布第一个吧</div> }}
/>
</Col>
<Col xs={0} md={6}>
<Card
title={<Space><StarFilled style={{ color: '#ffd700' }} /><span style={{ color: '#fff' }}>技术专家榜</span></Space>}
style={{ background: 'rgba(20,20,20,0.6)', border: '1px solid rgba(255,255,255,0.1)', backdropFilter: 'blur(10px)' }}
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
>
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
{starUsers.length > 0 ? (
starUsers.map(u => (
<div key={u.id} style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size="large" src={u.avatar_url} icon={<UserOutlined />} />
<div style={{ textAlign: 'left' }}>
<div style={{ color: '#fff', fontWeight: 'bold' }}>
{u.nickname} <StarFilled style={{ color: '#ffd700', fontSize: 12 }} />
</div>
<div style={{ color: '#666', fontSize: 12 }}>{u.title || '技术专家'}</div>
</div>
</div>
))
) : (
<div style={{ color: '#888' }}>暂无上榜专家</div>
)}
</div>
</Card>
<Card
title={<Space><SoundOutlined style={{ color: '#ff4d4f' }} /><span style={{ color: '#fff' }}>社区公告</span></Space>}
style={{ marginTop: 20, background: 'rgba(20,20,20,0.6)', border: '1px solid rgba(255,255,255,0.1)', backdropFilter: 'blur(10px)' }}
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
>
<List
size="small"
dataSource={announcements}
renderItem={item => (
<List.Item style={{ padding: '12px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'block' }}>
{item.display_image_url && (
<div style={{ marginBottom: 8 }}>
<img src={item.display_image_url} alt={item.title} style={{ width: '100%', borderRadius: 4 }} />
</div>
)}
<div style={{ color: '#fff', marginBottom: 4, fontWeight: 'bold' }}>
{item.link_url ? (
<a href={item.link_url} target="_blank" rel="noopener noreferrer" style={{ color: '#fff' }}>{item.title}</a>
) : (
<span>{item.title}</span>
)}
</div>
<div style={{ color: '#888', fontSize: 12 }}>
{item.content}
</div>
</List.Item>
)}
locale={{ emptyText: <div style={{ color: '#666', padding: '20px 0', textAlign: 'center' }}>暂无公告</div> }}
/>
</Card>
</Col>
</Row>
</div>
<CreateTopicModal
visible={createModalVisible}
onClose={() => setCreateModalVisible(false)}
onSuccess={() => fetchTopics(searchText, category)}
/>
<LoginModal
visible={loginModalVisible}
onClose={() => setLoginModalVisible(false)}
onLoginSuccess={() => {
setCreateModalVisible(true);
}}
/>
</div>
);
};
export default ForumList;

View File

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

View File

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

View File

@@ -1,342 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, List, Tag, Typography, message, Space, Statistic, Divider, Modal, Descriptions, Tabs } from 'antd';
import { MobileOutlined, LockOutlined, SearchOutlined, CarOutlined, InboxOutlined, SafetyCertificateOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, UserOutlined, EnvironmentOutlined, PhoneOutlined, CalendarOutlined } from '@ant-design/icons';
import { queryMyOrders, getMySignups } from '../api';
import { motion } from 'framer-motion';
import LoginModal from '../components/LoginModal';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
const { Title, Text, Paragraph } = Typography;
const MyOrders = () => {
const [loading, setLoading] = useState(false);
const [orders, setOrders] = useState([]);
const [activities, setActivities] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [currentOrder, setCurrentOrder] = useState(null);
const [loginVisible, setLoginVisible] = useState(false);
const navigate = useNavigate();
const { user, login } = useAuth();
useEffect(() => {
if (user) {
handleQueryData();
}
}, [user]);
const showDetail = (order) => {
setCurrentOrder(order);
setModalVisible(true);
};
const handleQueryData = async () => {
setLoading(true);
try {
const { default: api } = await import('../api');
// Parallel fetch
const [ordersRes, activitiesRes] = await Promise.allSettled([
api.get('/orders/'),
getMySignups()
]);
if (ordersRes.status === 'fulfilled') {
setOrders(ordersRes.value.data);
}
if (activitiesRes.status === 'fulfilled') {
setActivities(activitiesRes.value.data);
}
} catch (error) {
console.error(error);
message.error('查询出错');
} finally {
setLoading(false);
}
};
const getStatusTag = (status) => {
switch (status) {
case 'paid': return <Tag icon={<CheckCircleOutlined />} color="success">已支付</Tag>;
case 'pending': return <Tag icon={<ClockCircleOutlined />} color="warning">待支付</Tag>;
case 'shipped': return <Tag icon={<CarOutlined />} color="processing">已发货</Tag>;
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default">已取消</Tag>;
default: return <Tag>{status}</Tag>;
}
};
return (
<div style={{
minHeight: '80vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{ width: '100%', maxWidth: 1200 }}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#00b96b', marginBottom: 20 }} />
<Title level={2} style={{ color: '#fff', margin: 0, fontFamily: "'Orbitron', sans-serif" }}>我的订单</Title>
<Text style={{ color: '#666' }}>Secure Order Verification System</Text>
</div>
{!user ? (
<div style={{ textAlign: 'center', padding: 40, background: 'rgba(0,0,0,0.5)', borderRadius: 16 }}>
<Text style={{ color: '#fff', fontSize: 18, display: 'block', marginBottom: 20 }}>请先登录以查看您的订单</Text>
<Button type="primary" size="large" onClick={() => setLoginVisible(true)}>立即登录</Button>
</div>
) : (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<div style={{ marginBottom: 20, textAlign: 'right', color: '#fff' }}>
当前登录用户: <span style={{ color: '#00b96b', fontWeight: 'bold', marginRight: 10 }}>{user.nickname}</span>
<Button
onClick={handleQueryData}
loading={loading}
icon={<SearchOutlined />}
>
刷新
</Button>
</div>
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: <span style={{ fontSize: 16 }}>我的订单</span>,
children: (
<List
grid={{ gutter: 24, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
dataSource={orders}
loading={loading}
renderItem={order => (
<List.Item>
<Card
hoverable
onClick={() => showDetail(order)}
title={<Space><span style={{ color: '#fff' }}>订单号: {order.id}</span> {getStatusTag(order.status)}</Space>}
style={{
background: 'rgba(0,0,0,0.6)',
border: '1px solid rgba(255,255,255,0.1)',
marginBottom: 10,
backdropFilter: 'blur(10px)'
}}
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
bodyStyle={{ padding: '20px' }}
>
<div style={{ color: '#ccc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<Text strong style={{ color: '#00b96b', fontSize: 16 }}>{order.total_price} </Text>
<Text style={{ color: '#888' }}>{new Date(order.created_at).toLocaleString()}</Text>
</div>
<div style={{ background: 'rgba(255,255,255,0.05)', padding: 15, borderRadius: 8, marginBottom: 15 }}>
<Space align="center" size="middle">
{order.config_image ? (
<img
src={order.config_image}
alt={order.config_name}
style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}
/>
) : (
<div style={{
width: 60,
height: 60,
background: 'rgba(24,144,255,0.1)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(24,144,255,0.2)'
}}>
<InboxOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
)}
<div>
<div style={{ color: '#fff', fontSize: 16, fontWeight: '500', marginBottom: 4 }}>{order.config_name || `商品 ID: ${order.config}`}</div>
<div style={{ color: '#888' }}>数量: <span style={{ color: '#00b96b' }}>x{order.quantity}</span></div>
</div>
</Space>
</div>
{(order.courier_name || order.tracking_number) && (
<div style={{ background: 'rgba(24,144,255,0.1)', padding: 15, borderRadius: 8, border: '1px solid rgba(24,144,255,0.3)' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<CarOutlined style={{ color: '#1890ff', fontSize: 18 }} />
<Text style={{ color: '#fff', fontSize: 16 }}>物流信息</Text>
</Space>
<Divider style={{ margin: '8px 0', borderColor: 'rgba(255,255,255,0.1)' }} />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#aaa' }}>快递公司:</span>
<span style={{ color: '#fff' }}>{order.courier_name || '未知'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#aaa' }}>快递单号:</span>
{order.tracking_number ? (
<div onClick={(e) => e.stopPropagation()}>
<Paragraph
copyable={{ text: order.tracking_number, tooltips: ['复制', '已复制'] }}
style={{ color: '#fff', fontFamily: 'monospace', fontSize: 16, margin: 0 }}
>
{order.tracking_number}
</Paragraph>
</div>
) : (
<span style={{ color: '#fff', fontFamily: 'monospace', fontSize: 16 }}>暂无单号</span>
)}
</div>
</Space>
</div>
)}
</div>
</Card>
</List.Item>
)}
locale={{ emptyText: <div style={{ color: '#888', padding: 40, textAlign: 'center' }}>暂无订单信息</div> }}
/>
)
},
{
key: '2',
label: <span style={{ fontSize: 16 }}>我的活动</span>,
children: (
<List
grid={{ gutter: 24, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
dataSource={activities}
loading={loading}
renderItem={item => {
const activity = item.activity_info || item.activity || item;
return (
<List.Item>
<Card
hoverable
onClick={() => navigate(`/activity/${activity.id}`)}
cover={
<div style={{ height: 160, overflow: 'hidden' }}>
<img
alt={activity.title}
src={activity.cover_image || activity.banner_url || 'https://via.placeholder.com/400x200'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
}
style={{
background: 'rgba(0,0,0,0.6)',
border: '1px solid rgba(255,255,255,0.1)',
marginBottom: 10,
backdropFilter: 'blur(10px)',
overflow: 'hidden'
}}
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
bodyStyle={{ padding: '16px' }}
>
<div style={{ color: '#ccc' }}>
<Title level={4} style={{ color: '#fff', marginBottom: 10, fontSize: 18 }} ellipsis={{ rows: 1 }}>{activity.title}</Title>
<div style={{ marginBottom: 12 }}>
<Space>
<CalendarOutlined style={{ color: '#00b96b' }} />
<Text style={{ color: '#bbb' }}>{new Date(activity.start_time).toLocaleDateString()}</Text>
</Space>
</div>
<div style={{ marginBottom: 12 }}>
<Space>
<EnvironmentOutlined style={{ color: '#00f0ff' }} />
<Text style={{ color: '#bbb' }} ellipsis>{activity.location || '线上活动'}</Text>
</Space>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
<Tag color="blue">{activity.status || '已报名'}</Tag>
<Button type="primary" size="small" ghost>查看详情</Button>
</div>
</div>
</Card>
</List.Item>
);
}}
locale={{ emptyText: <div style={{ color: '#888', padding: 40, textAlign: 'center' }}>暂无活动报名</div> }}
/>
)
}
]} />
</motion.div>
)}
<Modal
title={<Title level={4} style={{ margin: 0 }}>订单详情</Title>}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={[
<Button key="close" onClick={() => setModalVisible(false)}>
关闭
</Button>
]}
width={600}
centered
>
{currentOrder && (
<Descriptions column={1} bordered size="middle" labelStyle={{ width: '140px', fontWeight: 'bold' }}>
<Descriptions.Item label="订单号">
<Paragraph copyable={{ text: currentOrder.id }} style={{ marginBottom: 0 }}>{currentOrder.id}</Paragraph>
</Descriptions.Item>
<Descriptions.Item label="商品名称">{currentOrder.config_name}</Descriptions.Item>
<Descriptions.Item label="下单时间">{new Date(currentOrder.created_at).toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="状态更新时间">{new Date(currentOrder.updated_at).toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="当前状态">{getStatusTag(currentOrder.status)}</Descriptions.Item>
<Descriptions.Item label="订单总价">
<Text strong style={{ color: '#00b96b' }}>¥{currentOrder.total_price}</Text>
</Descriptions.Item>
<Descriptions.Item label="收件人信息">
<Space direction="vertical" size={0}>
<Space><UserOutlined /> {currentOrder.customer_name}</Space>
<Space><PhoneOutlined /> {currentOrder.phone_number}</Space>
<Space align="start"><EnvironmentOutlined /> {currentOrder.shipping_address}</Space>
</Space>
</Descriptions.Item>
{currentOrder.salesperson_name && (
<Descriptions.Item label="订单推荐员">
<Space>
{currentOrder.salesperson_name}
{currentOrder.salesperson_code && <Tag color="blue">{currentOrder.salesperson_code}</Tag>}
</Space>
</Descriptions.Item>
)}
{(currentOrder.status === 'shipped' || currentOrder.courier_name) && (
<>
<Descriptions.Item label="快递公司">{currentOrder.courier_name || '未知'}</Descriptions.Item>
<Descriptions.Item label="快递单号">
{currentOrder.tracking_number ? (
<Paragraph copyable={{ text: currentOrder.tracking_number }} style={{ marginBottom: 0 }}>
{currentOrder.tracking_number}
</Paragraph>
) : '暂无单号'}
</Descriptions.Item>
</>
)}
</Descriptions>
)}
</Modal>
<LoginModal
visible={loginVisible}
onClose={() => setLoginVisible(false)}
onLoginSuccess={(userData) => {
login(userData);
if (userData.phone_number) {
handleQueryOrders(userData.phone_number);
}
}}
/>
</div>
</div>
);
};
export default MyOrders;

View File

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

View File

@@ -1,206 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { Button, message, Result, Spin } from 'antd';
import { WechatOutlined, AlipayCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { QRCodeSVG } from 'qrcode.react';
import { getOrder, initiatePayment, confirmPayment, nativePay, queryOrderStatus } from '../api';
import './Payment.css';
const Payment = () => {
const { orderId: initialOrderId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [currentOrderId, setCurrentOrderId] = useState(location.state?.order_id || initialOrderId);
const [order, setOrder] = useState(location.state?.orderInfo || null);
const [codeUrl, setCodeUrl] = useState(location.state?.codeUrl || null);
const [loading, setLoading] = useState(!location.state?.orderInfo && !location.state?.codeUrl);
const [paying, setPaying] = useState(!!location.state?.codeUrl);
const [paySuccess, setPaySuccess] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('wechat');
useEffect(() => {
if (codeUrl && !paying) {
setPaying(true);
}
}, [codeUrl]);
useEffect(() => {
console.log('Payment page state:', { currentOrderId, order, codeUrl, paying });
if (!order && !codeUrl) {
fetchOrder();
}
}, [currentOrderId]);
useEffect(() => {
if (paying && !codeUrl && order) {
handlePay();
}
}, [paying, codeUrl, order]);
// 轮询订单状态
useEffect(() => {
let timer;
if (paying && !paySuccess) {
timer = setInterval(async () => {
try {
const response = await queryOrderStatus(currentOrderId);
if (response.data.status === 'paid') {
setPaySuccess(true);
setPaying(false);
clearInterval(timer);
}
} catch (error) {
console.error('Check payment status failed:', error);
}
}, 3000);
}
return () => clearInterval(timer);
}, [paying, paySuccess, currentOrderId]);
const fetchOrder = async () => {
try {
const response = await getOrder(currentOrderId);
setOrder(response.data);
} catch (error) {
console.error('Failed to fetch order:', error);
// Fallback if getOrder API fails (404/405), we might show basic info or error
// Assuming for now it works or we handle it
message.error('无法获取订单信息,请重试');
} finally {
setLoading(false);
}
};
const handlePay = async () => {
if (paymentMethod === 'alipay') {
message.info('暂未开通支付宝支付,请使用微信支付');
return;
}
if (codeUrl) {
setPaying(true);
return;
}
if (!order) {
message.error('正在加载订单信息,请稍后...');
return;
}
setPaying(true);
try {
const orderData = {
goodid: order.config || order.goodid,
quantity: order.quantity,
customer_name: order.customer_name,
phone_number: order.phone_number,
shipping_address: order.shipping_address,
ref_code: order.ref_code
};
const response = await nativePay(orderData);
setCodeUrl(response.data.code_url);
if (response.data.order_id) {
setCurrentOrderId(response.data.order_id);
}
message.success('支付二维码已生成');
} catch (error) {
console.error(error);
message.error('生成支付二维码失败,请重试');
setPaying(false);
}
};
if (loading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /><div style={{ marginTop: 20 }}>正在加载订单信息...</div></div>;
if (paySuccess) {
return (
<div className="payment-container" style={{ borderColor: '#00b96b' }}>
<Result
status="success"
icon={<CheckCircleOutlined style={{ color: '#00b96b' }} />}
title={<span style={{ color: '#fff' }}>支付成功</span>}
subTitle={<span style={{ color: '#888' }}>订单 {currentOrderId} 已完成支付我们将尽快为您发货</span>}
extra={[
<Button type="primary" key="home" onClick={() => navigate('/')}>
返回首页
</Button>,
]}
/>
</div>
);
}
return (
<div className="payment-container">
<div className="payment-title">收银台</div>
{order ? (
<>
<div className="payment-amount">¥{order.total_price}</div>
<div className="payment-info">
<p><strong>订单编号</strong> {order.id}</p>
<p><strong>商品名称</strong> {order.config_name || 'AI 硬件设备'}</p>
<p><strong>收货人</strong> {order.customer_name}</p>
</div>
</>
) : (
<div className="payment-info">
<p>订单 ID: {currentOrderId}</p>
<p>无法加载详情但您可以尝试支付</p>
</div>
)}
<div style={{ color: '#fff', marginBottom: 15, textAlign: 'left' }}>选择支付方式</div>
<div className="payment-method">
<div
className={`payment-method-item ${paymentMethod === 'wechat' ? 'active' : ''}`}
onClick={() => setPaymentMethod('wechat')}
>
<WechatOutlined style={{ color: '#09BB07', fontSize: 24, verticalAlign: 'middle', marginRight: 8 }} />
微信支付
</div>
<div
className={`payment-method-item ${paymentMethod === 'alipay' ? 'active' : ''}`}
onClick={() => setPaymentMethod('alipay')}
>
<AlipayCircleOutlined style={{ color: '#1677FF', fontSize: 24, verticalAlign: 'middle', marginRight: 8 }} />
支付宝
</div>
</div>
{paying && (
<div style={{ margin: '20px 0', padding: 20, background: '#fff', borderRadius: 8, display: 'inline-block', minWidth: 240, minHeight: 280 }}>
{codeUrl ? (
<>
<div style={{ background: '#fff', padding: '10px', borderRadius: '4px', display: 'inline-block' }}>
<QRCodeSVG value={codeUrl} size={200} />
</div>
<p style={{ color: '#000', marginTop: 15, fontWeight: 'bold', fontSize: 18 }}>请使用微信扫码支付</p>
<p style={{ color: '#666', fontSize: 14 }}>支付完成后将自动跳转</p>
</>
) : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<Spin />
<div style={{ marginTop: 10 }}>正在生成支付二维码...</div>
</div>
)}
</div>
)}
{!paying && (
<Button
type="primary"
size="large"
block
onClick={handlePay}
style={{ height: 50, fontSize: 18, background: paymentMethod === 'wechat' ? '#09BB07' : '#1677FF' }}
>
立即支付
</Button>
)}
</div>
);
};
export default Payment;

View File

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

View File

@@ -1,308 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Row, Col, Tag, Statistic, Modal, Form, Input, InputNumber, message, Spin, Descriptions, Radio, Alert } from 'antd';
import { ShoppingCartOutlined, SafetyCertificateOutlined, ThunderboltOutlined, EyeOutlined, StarOutlined } from '@ant-design/icons';
import { getConfigs, createOrder, nativePay } from '../api';
import ModelViewer from '../components/ModelViewer';
import { useAuth } from '../context/AuthContext';
import './ProductDetail.css';
const ProductDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const { user } = useAuth();
// 优先从 URL 获取,如果没有则从 localStorage 获取,不再默认绑定 flw666
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
useEffect(() => {
// 自动填充用户信息
if (user) {
form.setFieldsValue({
phone_number: user.phone_number,
// 如果后端返回了地址信息,这里也可以填充
// shipping_address: user.shipping_address
});
}
}, [isModalOpen, user]); // 当弹窗打开或用户状态变化时填充
useEffect(() => {
console.log('[ProductDetail] Current ref_code:', refCode);
}, [refCode]);
useEffect(() => {
fetchProduct();
}, [id]);
const fetchProduct = async () => {
try {
const response = await getConfigs();
const found = response.data.find(p => String(p.id) === id);
if (found) {
setProduct(found);
} else {
message.error('未找到该产品');
navigate('/');
}
} catch (error) {
console.error('Failed to fetch product:', error);
message.error('加载失败');
} finally {
setLoading(false);
}
};
const handleBuy = async (values) => {
setSubmitting(true);
try {
const isPickup = values.delivery_method === 'pickup';
const orderData = {
goodid: product.id,
quantity: values.quantity,
customer_name: values.customer_name,
phone_number: values.phone_number,
// 如果是自提,手动设置地址,否则使用表单中的地址
shipping_address: isPickup ? '线下自提' : values.shipping_address,
ref_code: refCode
};
console.log('提交订单数据:', orderData); // 调试日志
const response = await nativePay(orderData);
message.success('订单已创建,请完成支付');
navigate(`/payment/${response.data.order_id}`, {
state: {
codeUrl: response.data.code_url,
order_id: response.data.order_id,
orderInfo: {
...orderData,
id: response.data.order_id,
config_name: product.name,
total_price: product.price * values.quantity
}
}
});
} catch (error) {
console.error(error);
message.error('创建订单失败,请检查填写信息');
} finally {
setSubmitting(false);
}
};
const getModelPaths = (p) => {
if (!p) return null;
// 优先使用后台配置的 3D 模型 URL
if (p.model_3d_url) {
return { obj: p.model_3d_url };
}
return null;
};
const modelPaths = getModelPaths(product);
const renderIcon = (feature) => {
if (feature.display_icon) {
return <img src={feature.display_icon} alt={feature.title} style={{ width: 60, height: 60, objectFit: 'contain', marginBottom: 20 }} />;
}
const iconProps = { style: { fontSize: 60, color: '#00b96b', marginBottom: 20 } };
switch(feature.icon_name) {
case 'SafetyCertificate':
return <SafetyCertificateOutlined {...iconProps} />;
case 'Eye':
return <EyeOutlined {...iconProps} style={{ ...iconProps.style, color: '#1890ff' }} />;
case 'Thunderbolt':
return <ThunderboltOutlined {...iconProps} style={{ ...iconProps.style, color: '#faad14' }} />;
default:
return <StarOutlined {...iconProps} />;
}
};
if (loading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
if (!product) return null;
return (
<div className="product-detail-container" style={{ paddingBottom: '60px' }}>
{/* Hero Section */}
<Row gutter={40} align="middle" style={{ minHeight: '60vh' }}>
<Col xs={24} md={12}>
<div style={{
height: 400,
background: 'radial-gradient(circle, #2a2a2a 0%, #000 100%)',
borderRadius: 20,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: '1px solid #333',
overflow: 'hidden'
}}>
{modelPaths ? (
<ModelViewer objPath={modelPaths.obj} mtlPath={modelPaths.mtl} />
) : product.static_image_url ? (
<img src={product.static_image_url} alt={product.name} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 120, color: '#00b96b' }} />
)}
</div>
</Col>
<Col xs={24} md={12}>
<h1 style={{ fontSize: 48, fontWeight: 'bold', color: '#fff' }}>{product.name}</h1>
<p style={{ fontSize: 20, color: '#888', margin: '20px 0' }}>{product.description}</p>
<div style={{ marginBottom: 30, display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Tag color="cyan" style={{ background: 'rgba(0,255,255,0.1)', border: '1px solid cyan', padding: '4px 12px', fontSize: '14px', margin: 0 }}>{product.chip_type}</Tag>
{product.has_camera && <Tag color="blue" style={{ background: 'rgba(0,0,255,0.1)', border: '1px solid blue', padding: '4px 12px', fontSize: '14px', margin: 0 }}>高清摄像头</Tag>}
{product.has_microphone && <Tag color="purple" style={{ background: 'rgba(114,46,209,0.1)', border: '1px solid #722ed1', padding: '4px 12px', fontSize: '14px', margin: 0 }}>阵列麦克风</Tag>}
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 20, marginBottom: 20 }}>
<Statistic title="售价" value={product.price} prefix="¥" valueStyle={{ color: '#00b96b', fontSize: 36 }} titleStyle={{ color: '#888' }} />
<Statistic title="库存" value={product.stock} suffix="件" valueStyle={{ color: product.stock < 5 ? '#ff4d4f' : '#fff', fontSize: 20 }} titleStyle={{ color: '#888' }} />
</div>
{product.stock < 5 && product.stock > 0 && (
<Alert message={`库存紧张,仅剩 ${product.stock} 件!`} type="warning" showIcon style={{ marginBottom: 20, background: 'rgba(250, 173, 20, 0.1)', border: '1px solid #faad14', color: '#faad14' }} />
)}
{product.stock === 0 && (
<Alert message="该商品暂时缺货" type="error" showIcon style={{ marginBottom: 20 }} />
)}
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined />}
onClick={() => setIsModalOpen(true)}
disabled={product.stock === 0}
style={{ height: 50, padding: '0 40px', fontSize: 18 }}
>
{product.stock === 0 ? '暂时缺货' : '立即购买'}
</Button>
</Col>
</Row>
{/* Feature Section */}
<div style={{ marginTop: 100 }}>
{product.features && product.features.length > 0 ? (
product.features.map((feature, index) => (
<div className="feature-section" key={index}>
{renderIcon(feature)}
<div className="feature-title">{feature.title}</div>
<div className="feature-desc">{feature.description}</div>
</div>
))
) : (
// Fallback content if no features are configured
<>
<div className="feature-section">
<SafetyCertificateOutlined style={{ fontSize: 60, color: '#00b96b', marginBottom: 20 }} />
<div className="feature-title">工业级安全标准</div>
<div className="feature-desc">
采用军工级加密芯片保障您的数据隐私安全无论是边缘计算还是云端同步全程加密传输 AI 应用无后顾之忧
</div>
</div>
<div className="feature-section">
<EyeOutlined style={{ fontSize: 60, color: '#1890ff', marginBottom: 20 }} />
<div className="feature-title">超清视觉感知</div>
<div className="feature-desc">
搭载 4K 高清摄像头与 AI 视觉算法实时捕捉每一个细节支持人脸识别物体检测姿态分析等多种视觉任务
</div>
</div>
<div className="feature-section">
<ThunderboltOutlined style={{ fontSize: 60, color: '#faad14', marginBottom: 20 }} />
<div className="feature-title">极致性能释放</div>
<div className="feature-desc">
{product.chip_type} 强劲核心提供高达 XX TOPS 的算力支持低功耗设计满足 24 小时全天候运行需求
</div>
</div>
</>
)}
{product.display_detail_image ? (
<div style={{
margin: '60px auto',
maxWidth: '900px',
width: '100%',
overflow: 'hidden',
borderRadius: 12,
boxShadow: '0 10px 40px rgba(0,0,0,0.5)'
}}>
<img src={product.display_detail_image} alt="产品详情" style={{ width: '100%', display: 'block', height: 'auto' }} />
</div>
) : (
<div style={{ margin: '60px 0', height: 800, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#333', fontSize: 24, border: '1px dashed #333' }}>
产品详情长图展示区域 (请在后台配置)
</div>
)}
</div>
{/* Order Modal */}
<Modal
title="填写收货信息"
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
onFinish={handleBuy}
initialValues={{ quantity: 1, delivery_method: 'shipping' }}
>
<Form.Item label="配送方式" name="delivery_method">
<Radio.Group buttonStyle="solid">
<Radio.Button value="shipping">快递配送</Radio.Button>
<Radio.Button value="pickup">线下自提</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="购买数量" name="quantity" rules={[{ required: true }]}>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="收货人姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="张三" />
</Form.Item>
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
<Input placeholder="13800000000" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.delivery_method !== currentValues.delivery_method}
>
{({ getFieldValue }) =>
getFieldValue('delivery_method') === 'shipping' ? (
<Form.Item label="收货地址" name="shipping_address" rules={[{ required: true, message: '请输入地址' }]}>
<Input.TextArea rows={3} placeholder="北京市..." />
</Form.Item>
) : (
<div style={{ marginBottom: 24, padding: '12px', background: '#f5f5f5', borderRadius: '4px', border: '1px solid #d9d9d9' }}>
<p style={{ margin: 0, color: '#666' }}>自提地址昆明市云纺国际商厦B座1406</p>
<p style={{ margin: 0, fontSize: '12px', color: '#999' }}>请在工作日 9:00 - 18:00 期间前往提货</p>
</div>
)
}
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>提交订单</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default ProductDetail;

View File

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

View File

@@ -1,286 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Typography, Button, Spin, Empty, Descriptions, Tag, Row, Col, Modal, Form, Input, message } from 'antd';
import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, FormOutlined } from '@ant-design/icons';
import { getVCCourseDetail, createOrder } from '../api';
import { motion } from 'framer-motion';
const { Title, Paragraph } = Typography;
const VCCourseDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [course, setCourse] = useState(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
// 优先从 URL 获取,如果没有则从 localStorage 获取
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await getVCCourseDetail(id);
setCourse(response.data);
} catch (error) {
console.error("Failed to fetch course detail:", error);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [id]);
const handleEnroll = async (values) => {
setSubmitting(true);
try {
const orderData = {
course: course.id,
customer_name: values.customer_name,
phone_number: values.phone_number,
ref_code: refCode,
quantity: 1,
// 将其他信息放入收货地址字段中
shipping_address: `[课程报名] 微信号: ${values.wechat_id || '无'}, 邮箱: ${values.email || '无'}, 备注: ${values.message || '无'}`
};
await createOrder(orderData);
message.success('报名咨询已提交,我们的课程顾问将尽快与您联系!');
setIsModalOpen(false);
} catch (error) {
console.error(error);
message.error('提交失败,请重试');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 20 }}>Loading...</div>
</div>
);
}
if (!course) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Empty description="Course not found" />
<Button type="primary" onClick={() => navigate('/courses')} style={{ marginTop: 20 }}>
Return to Courses
</Button>
</div>
);
}
return (
<div style={{ padding: '20px 0', minHeight: '80vh' }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
style={{ color: '#fff', marginBottom: 20 }}
onClick={() => navigate('/courses')}
>
返回课程列表
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Row gutter={[40, 40]}>
<Col xs={24} md={16}>
<div style={{ textAlign: 'left', marginBottom: 40 }}>
<div style={{ display: 'flex', gap: '10px', marginBottom: 10 }}>
{course.tag && <Tag color="volcano">{course.tag}</Tag>}
<Tag color={course.course_type === 'hardware' ? 'purple' : 'cyan'}>
{course.course_type_display || (course.course_type === 'hardware' ? '硬件课程' : '软件课程')}
</Tag>
</div>
<Title level={1} style={{ color: '#fff', marginTop: 0 }}>
{course.title}
</Title>
<Paragraph style={{ color: '#888', fontSize: 18 }}>
{course.description}
</Paragraph>
<div style={{
marginTop: 30,
background: 'rgba(255,255,255,0.03)',
padding: '24px',
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.08)',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}>
<Title level={4} style={{ color: '#fff', marginBottom: 20, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 18, background: '#00f0ff', marginRight: 10, borderRadius: 2 }} />
课程信息
</Title>
<Descriptions
column={{ xs: 1, sm: 2, md: 3 }}
labelStyle={{ color: '#888', fontWeight: 'normal' }}
contentStyle={{ color: '#fff', fontWeight: '500' }}
>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><UserOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 讲师</span>}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{course.instructor_avatar_url && (
<img src={course.instructor_avatar_url} alt="avatar" style={{ width: 24, height: 24, borderRadius: '50%', marginRight: 8, objectFit: 'cover' }} />
)}
<span>{course.instructor}</span>
{course.instructor_title && (
<span style={{
fontSize: 12,
background: 'rgba(0, 240, 255, 0.1)',
color: '#00f0ff',
padding: '2px 6px',
borderRadius: 4,
marginLeft: 8
}}>
{course.instructor_title}
</span>
)}
</div>
</Descriptions.Item>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><ClockCircleOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 时长</span>}>
{course.duration}
</Descriptions.Item>
<Descriptions.Item label={<span style={{ display: 'flex', alignItems: 'center' }}><BookOutlined style={{ marginRight: 8, color: '#00f0ff' }} /> 课时</span>}>
{course.lesson_count} 课时
</Descriptions.Item>
</Descriptions>
{/* 讲师简介 */}
{course.instructor_desc && (
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid rgba(255,255,255,0.05)', color: '#aaa', fontSize: 14 }}>
<span style={{ color: '#666', marginRight: 10 }}>讲师简介:</span>
{course.instructor_desc}
</div>
)}
</div>
{/* 课程详细内容区域 */}
{course.content && (
<div style={{ marginTop: 40 }}>
<Title level={3} style={{ color: '#fff', marginBottom: 20 }}>课程大纲与详情</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px', whiteSpace: 'pre-line' }}>
{course.content}
</div>
</div>
)}
</div>
{course.display_detail_image ? (
<div style={{
width: '100%',
maxWidth: '900px',
margin: '40px auto 0',
background: '#111',
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 10px 40px rgba(0, 240, 255, 0.1)`,
border: `1px solid rgba(0, 240, 255, 0.2)`
}}>
<img
src={course.display_detail_image}
alt={course.title}
style={{ width: '100%', display: 'block', height: 'auto' }}
/>
</div>
) : null}
</Col>
<Col xs={24} md={8}>
<div style={{ position: 'sticky', top: 100 }}>
<div style={{
background: '#1f1f1f',
padding: 30,
borderRadius: 16,
border: `1px solid rgba(0, 240, 255, 0.2)`,
boxShadow: `0 0 20px rgba(0, 240, 255, 0.05)`
}}>
<Title level={3} style={{ color: '#fff', marginTop: 0 }}>报名咨询</Title>
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 20 }}>
{parseFloat(course.price) > 0 ? (
<>
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>¥{course.price}</span>
</>
) : (
<span style={{ fontSize: 36, color: '#00f0ff', fontWeight: 'bold' }}>免费咨询</span>
)}
</div>
<Button
type="primary"
size="large"
block
icon={<FormOutlined />}
style={{
height: 50,
background: '#00f0ff',
borderColor: '#00f0ff',
color: '#000',
fontWeight: 'bold',
fontSize: '16px'
}}
onClick={() => setIsModalOpen(true)}
>
立即报名 / 咨询
</Button>
<p style={{ color: '#666', marginTop: 15, fontSize: 12, textAlign: 'center' }}>
* 提交后我们的顾问将尽快与您联系确认
</p>
</div>
</div>
</Col>
</Row>
</motion.div>
{/* Enroll Modal */}
<Modal
title={`报名/咨询 - ${course.title}`}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
destroyOnHidden
>
<p style={{ marginBottom: 20, color: '#666' }}>请填写您的联系方式我们将为您安排课程顾问</p>
<Form
form={form}
layout="vertical"
onFinish={handleEnroll}
>
<Form.Item label="您的姓名" name="customer_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="例如:李同学" />
</Form.Item>
<Form.Item label="联系电话" name="phone_number" rules={[{ required: true, message: '请输入电话' }]}>
<Input placeholder="13800000000" />
</Form.Item>
<Form.Item label="微信号" name="wechat_id">
<Input placeholder="选填,方便微信沟通" />
</Form.Item>
<Form.Item label="电子邮箱" name="email" rules={[{ type: 'email' }]}>
<Input placeholder="example@email.com" />
</Form.Item>
<Form.Item label="备注/留言" name="message">
<Input.TextArea rows={4} placeholder="您想了解的任何问题..." />
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>提交报名</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default VCCourseDetail;

View File

@@ -1,129 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Button, Typography, Spin, Row, Col, Empty, Tag } from 'antd';
import { ReadOutlined, ClockCircleOutlined, UserOutlined, BookOutlined } from '@ant-design/icons';
import { getVCCourses } from '../api';
const { Title, Paragraph } = Typography;
const VCCourses = () => {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
const fetchCourses = async () => {
try {
const res = await getVCCourses();
setCourses(res.data);
} catch (error) {
console.error("Failed to fetch VC Courses:", error);
} finally {
setLoading(false);
}
}
fetchCourses();
}, []);
if (loading) return <div style={{ textAlign: 'center', padding: 100 }}><Spin size="large" /></div>;
return (
<div style={{ padding: '40px 0', minHeight: '80vh', position: 'relative' }}>
<div style={{ textAlign: 'center', marginBottom: 60, position: 'relative', zIndex: 2 }}>
<Title level={1} style={{ color: '#fff', letterSpacing: 4 }}>
VC <span style={{ color: '#00f0ff' }}>CODING COURSES</span>
</Title>
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
探索 VB Coding 软件与硬件课程开启您的编程之旅
</Paragraph>
</div>
{courses.length === 0 ? (
<div style={{ textAlign: 'center', marginTop: 100, zIndex: 2, position: 'relative' }}>
<Empty description={<span style={{ color: '#666' }}>暂无课程内容</span>} />
</div>
) : (
<Row gutter={[32, 32]} justify="center" style={{ padding: '0 20px', position: 'relative', zIndex: 2 }}>
{courses.map((item, index) => (
<Col xs={24} md={12} lg={8} key={item.id}>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
onClick={() => navigate(`/courses/${item.id}`)}
style={{ cursor: 'pointer' }}
>
<div style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(0,240,255,0.2)',
borderRadius: 12,
overflow: 'hidden',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
<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' }} />
) : (
<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, 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>
</motion.div>
</Col>
))}
</Row>
)}
{/* 装饰性背景 */}
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: `
radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.05) 0%, transparent 50%)
`,
zIndex: 0,
pointerEvents: 'none'
}} />
<div style={{
position: 'fixed',
bottom: 0,
width: '100%',
height: '300px',
background: `linear-gradient(to top, rgba(0,0,0,0.8), transparent)`,
zIndex: 1,
pointerEvents: 'none'
}} />
</div>
);
};
export default VCCourses;

View File

@@ -1,334 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, useScroll, useTransform } from 'framer-motion';
import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, UserOutlined } from '@ant-design/icons';
import confetti from 'canvas-confetti';
import { message, Spin, Button, Result, Modal, Form, Input } from 'antd';
import { getActivityDetail, signUpActivity } from '../../api';
import styles from '../../components/activity/activity.module.less';
import { pageTransition, buttonTap } from '../../animation';
import LoginModal from '../../components/LoginModal';
import { useAuth } from '../../context/AuthContext';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
const ActivityDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { scrollY } = useScroll();
const { login } = useAuth();
const [loginVisible, setLoginVisible] = useState(false);
const [signupFormVisible, setSignupFormVisible] = useState(false);
const [form] = Form.useForm();
// Header animation: transparent to white with shadow
const headerBg = useTransform(scrollY, [0, 60], ['rgba(255,255,255,0)', 'rgba(255,255,255,1)']);
const headerShadow = useTransform(scrollY, [0, 60], ['none', '0 2px 8px rgba(0,0,0,0.1)']);
const headerColor = useTransform(scrollY, [0, 60], ['rgba(255,255,255,1)', 'rgba(0,0,0,0.85)']);
const titleOpacity = useTransform(scrollY, [100, 200], [0, 1]);
const { data: activity, isLoading, error, refetch } = useQuery({
queryKey: ['activity', id],
queryFn: async () => {
try {
const res = await getActivityDetail(id);
return res.data;
} catch (err) {
throw new Error(err.response?.data?.detail || 'Failed to load activity');
}
},
staleTime: 0, // Ensure fresh data
refetchOnMount: 'always', // Force refetch on mount
});
//// /
// Force a refresh if needed (as requested by user)
useEffect(() => {
// 1. Force React Query refetch
refetch();
// 2. Hard refresh logic after 1 second delay
const timer = setTimeout(() => {
const hasRefreshedKey = `has_refreshed_activity_${id}`;
if (!sessionStorage.getItem(hasRefreshedKey)) {
sessionStorage.setItem(hasRefreshedKey, 'true');
window.location.reload();
}
}, 0);
return () => clearTimeout(timer);
}, [id, refetch]);
const signUpMutation = useMutation({
mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }),
onSuccess: () => {
message.success('报名成功!');
setSignupFormVisible(false);
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ['#00b96b', '#1890ff', '#ffffff']
});
queryClient.invalidateQueries(['activity', id]);
queryClient.invalidateQueries(['activities']);
},
onError: (err) => {
message.error(err.response?.data?.detail || err.response?.data?.error || '报名失败,请稍后重试');
}
});
const handleShare = async () => {
const url = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: activity?.title,
text: '来看看这个精彩活动!',
url: url
});
} catch (err) {
console.log('Share canceled');
}
} else {
navigator.clipboard.writeText(url);
message.success('链接已复制到剪贴板');
}
};
const handleSignUp = () => {
if (!localStorage.getItem('token')) {
message.warning('请先登录后报名');
setLoginVisible(true);
return;
}
// Check if we need to collect info
if (activity.signup_form_config && activity.signup_form_config.length > 0) {
setSignupFormVisible(true);
} else {
// Direct signup if no info needed
signUpMutation.mutate({});
}
};
const handleFormSubmit = (values) => {
signUpMutation.mutate(values);
};
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#1f1f1f' }}>
<Spin size="large" />
</div>
);
}
if (error) {
return (
<div style={{ padding: 40, background: '#1f1f1f', minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Result
status="error"
title="加载失败"
subTitle={error.message}
extra={[
<Button type="primary" key="back" onClick={() => navigate(-1)}>
返回列表
</Button>
]}
/>
</div>
);
}
const cleanUrl = (url) => {
if (!url) return '';
return url.replace(/[`\s]/g, '');
};
return (
<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={pageTransition}
style={{ background: '#1f1f1f', minHeight: '100vh', color: '#fff' }}
>
{/* Sticky Header */}
<motion.div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 60,
zIndex: 100,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px',
background: headerBg,
boxShadow: headerShadow,
}}
>
<motion.div
onClick={() => navigate(-1)}
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
>
<ArrowLeftOutlined />
</motion.div>
<motion.div
style={{ color: headerColor, fontWeight: 600, opacity: titleOpacity }}
>
{activity.title}
</motion.div>
<motion.div
onClick={handleShare}
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
>
<ShareAltOutlined />
</motion.div>
</motion.div>
{/* Hero Image with LayoutId for shared transition */}
<div className={styles.detailHeader}>
<motion.img
layoutId={`activity-card-${id}`}
src={activity.display_banner_url || cleanUrl(activity.banner_url) || activity.cover_image || 'https://via.placeholder.com/800x600'}
alt={activity.title}
className={styles.detailImage}
/>
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
height: '50%',
background: 'linear-gradient(to top, #1f1f1f, transparent)'
}} />
</div>
{/* Content */}
<div className={styles.detailContent}>
<div className={styles.infoCard}>
<h1 style={{ fontSize: 28, marginBottom: 16, color: '#fff' }}>{activity.title}</h1>
<div style={{ display: 'flex', gap: 20, marginBottom: 16, color: 'rgba(255,255,255,0.7)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CalendarOutlined />
<span>{activity.start_time ? new Date(activity.start_time).toLocaleDateString() : 'TBD'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ClockCircleOutlined />
<span>{activity.start_time ? new Date(activity.start_time).toLocaleTimeString() : 'TBD'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EnvironmentOutlined />
<span>{activity.location || '线上活动'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<UserOutlined />
<span>{activity.current_signups || 0} / {activity.max_participants} 已报名</span>
</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<span className={styles.statusTag}>
{activity.status || (new Date() < new Date(activity.start_time) ? '报名中' : '已结束')}
</span>
</div>
</div>
<div className={styles.richText}>
<h3>活动详情</h3>
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{activity.description || activity.content || '暂无详情描述'}
</ReactMarkdown>
</div>
</div>
</div>
{/* Fixed Footer */}
<div className={styles.fixedFooter}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>距离报名截止</span>
<span style={{ color: '#00b96b', fontWeight: 'bold' }}>
{/* Simple countdown placeholder */}
3 12小时
</span>
</div>
<motion.button
className={styles.actionBtn}
variants={buttonTap}
whileTap="tap"
onClick={handleSignUp}
disabled={signUpMutation.isPending || activity.is_signed_up}
>
{signUpMutation.isPending ? '提交中...' : activity.is_signed_up ? '已报名' : '立即报名'}
</motion.button>
</div>
<LoginModal
visible={loginVisible}
onClose={() => setLoginVisible(false)}
onLoginSuccess={(userData) => {
login(userData);
// Auto trigger signup after login if needed, or just let user click again
}}
/>
<Modal
title="填写报名信息"
open={signupFormVisible}
onCancel={() => setSignupFormVisible(false)}
onOk={form.submit}
confirmLoading={signUpMutation.isPending}
destroyOnHidden
>
<Form form={form} onFinish={handleFormSubmit} layout="vertical">
{activity.signup_form_config && activity.signup_form_config.map(field => (
<Form.Item
key={field.name}
name={field.name}
label={field.label}
rules={[{ required: field.required, message: `请填写${field.label}` }]}
>
<Input placeholder={`请输入${field.label}`} />
</Form.Item>
))}
</Form>
</Modal>
</motion.div>
);
};
export default ActivityDetail;

View File

@@ -1,69 +0,0 @@
/* Global Theme Variables */
:global {
:root {
/* Colors */
--primary-color: #00b96b;
--secondary-color: #1890ff;
--background-dark: #1f1f1f;
--background-card: #2a2a2a;
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.65);
--border-color: rgba(255, 255, 255, 0.1);
/* Typography */
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
/* Layout */
--border-radius-base: 8px;
--border-radius-lg: 16px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
/* Shadows */
--box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15);
--box-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.25);
}
}
/* Mixins (Less Variables for module usage if needed) */
@primary-color: var(--primary-color);
@secondary-color: var(--secondary-color);
@background-dark: var(--background-dark);
@background-card: var(--background-card);
@text-primary: var(--text-primary);
@text-secondary: var(--text-secondary);
@border-radius-base: var(--border-radius-base);
.glass-panel {
background: rgba(42, 42, 42, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius-lg);
}
.section-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
&::before {
content: '';
display: block;
width: 4px;
height: 24px;
background: var(--primary-color);
border-radius: 2px;
}
}