diff --git a/backend/shop/admin.py b/backend/shop/admin.py index 135afc5..7817c08 100644 --- a/backend/shop/admin.py +++ b/backend/shop/admin.py @@ -6,7 +6,7 @@ from django.urls import path, reverse from django.shortcuts import redirect from unfold.admin import ModelAdmin, TabularInline 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 from io import BytesIO import base64 @@ -478,3 +478,9 @@ class WithdrawalAdmin(ModelAdmin): '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') diff --git a/backend/shop/apps.py b/backend/shop/apps.py index a5c0262..caf0bb9 100644 --- a/backend/shop/apps.py +++ b/backend/shop/apps.py @@ -3,3 +3,7 @@ from django.apps import AppConfig class ShopConfig(AppConfig): name = 'shop' + verbose_name = "商城管理" + + def ready(self): + import shop.signals diff --git a/backend/shop/migrations/0031_adminphonenumber.py b/backend/shop/migrations/0031_adminphonenumber.py new file mode 100644 index 0000000..d36c7ce --- /dev/null +++ b/backend/shop/migrations/0031_adminphonenumber.py @@ -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': '管理员通知手机号', + }, + ), + ] diff --git a/backend/shop/models.py b/backend/shop/models.py index 0c739b8..42346c7 100644 --- a/backend/shop/models.py +++ b/backend/shop/models.py @@ -402,3 +402,21 @@ class CourseEnrollment(models.Model): class Meta: verbose_name = "课程报名" 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 = "管理员通知手机号" diff --git a/backend/shop/signals.py b/backend/shop/signals.py new file mode 100644 index 0000000..9236fec --- /dev/null +++ b/backend/shop/signals.py @@ -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)}") diff --git a/backend/shop/sms_utils.py b/backend/shop/sms_utils.py new file mode 100644 index 0000000..80eb105 --- /dev/null +++ b/backend/shop/sms_utils.py @@ -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) diff --git a/deploy_market_page 2.sh b/deploy_market_page 2.sh new file mode 100644 index 0000000..73fc6d0 --- /dev/null +++ b/deploy_market_page 2.sh @@ -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===== 操作完成!=====" \ No newline at end of file diff --git a/frontend/public/big_logo.png b/frontend/public/big_logo.png deleted file mode 100644 index 2242f36..0000000 Binary files a/frontend/public/big_logo.png and /dev/null differ diff --git a/frontend/public/gXEu5E01.svg b/frontend/public/gXEu5E01.svg deleted file mode 100644 index 6768690..0000000 --- a/frontend/public/gXEu5E01.svg +++ /dev/null @@ -1,106 +0,0 @@ - - - - -Created by potrace 1.10, written by Peter Selinger 2001-2011 - - - - - - - - - - - - - - - - - diff --git a/frontend/public/liangji_black.png b/frontend/public/liangji_black.png deleted file mode 100644 index c2396e0..0000000 Binary files a/frontend/public/liangji_black.png and /dev/null differ diff --git a/frontend/public/liangji_logo.svg b/frontend/public/liangji_logo.svg deleted file mode 100644 index 25f8a5d..0000000 --- a/frontend/public/liangji_logo.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - QUANT SPEED - - \ No newline at end of file diff --git a/frontend/public/liangji_white.png b/frontend/public/liangji_white.png deleted file mode 100644 index ddf0cd6..0000000 Binary files a/frontend/public/liangji_white.png and /dev/null differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 7ca6634..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 4714660..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -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 ( - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ) -} - -export default App diff --git a/frontend/src/animation.js b/frontend/src/animation.js deleted file mode 100644 index d083bf1..0000000 --- a/frontend/src/animation.js +++ /dev/null @@ -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 } - }, -}; diff --git a/frontend/src/api.js b/frontend/src/api.js deleted file mode 100644 index 5d074ac..0000000 --- a/frontend/src/api.js +++ /dev/null @@ -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; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/CreateTopicModal.jsx b/frontend/src/components/CreateTopicModal.jsx deleted file mode 100644 index 6ffd63f..0000000 --- a/frontend/src/components/CreateTopicModal.jsx +++ /dev/null @@ -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\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 ( - -
- - - - -
- - - - - - - -
- - -
-
- - - -
- - { - setContent(val); - form.setFieldsValue({ content: val }); - }} - height={400} - previewOptions={{ - rehypePlugins: [[rehypeKatex]], - remarkPlugins: [[remarkMath]], - }} - /> -
-
- - -
- - -
-
-
-
- ); -}; - -export default CreateTopicModal; \ No newline at end of file diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx deleted file mode 100644 index 97b75dd..0000000 --- a/frontend/src/components/Layout.jsx +++ /dev/null @@ -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: , - onClick: () => setProfileVisible(true) - }, - { - key: 'logout', - label: '退出登录', - icon: , - onClick: handleLogout - } - ] - }; - - const items = [ - { - key: '/', - icon: , - label: 'AI 硬件', - }, - { - key: '/forum', - icon: , - label: '技术论坛', - }, - { - key: '/services', - icon: , - label: 'AI 服务', - }, - { - key: '/courses', - icon: , - label: 'VC 课程', - }, - { - key: '/my-orders', - icon: , - label: '我的订单', - }, - ]; - - const handleMenuClick = (key) => { - navigate(key); - setMobileMenuOpen(false); - }; - - return ( - - - -
-
- navigate('/')} - > - Quant Speed Logo - - - {/* Desktop Menu */} -
- handleMenuClick(e.key)} - style={{ - background: 'transparent', - borderBottom: 'none', - display: 'flex', - justifyContent: 'flex-end', - minWidth: '400px', - marginRight: '20px' - }} - /> - - {user ? ( -
- {/* 小程序图标状态 */} - - - -
- } style={{ marginRight: 8 }} /> - {user.nickname} -
-
-
- ) : ( - - )} -
- - - {/* Mobile Menu Button */} -
-
- - {/* Mobile Drawer Menu */} - 导航菜单} - placement="right" - onClose={() => setMobileMenuOpen(false)} - open={mobileMenuOpen} - styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }} - > -
- {user ? ( -
- } - size="large" - style={{ marginBottom: 10, cursor: 'pointer' }} - onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} - /> -
{ setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}> - {user.nickname} -
- -
- ) : ( - - )} -
- handleMenuClick(e.key)} - style={{ background: 'transparent', borderRight: 'none' }} - /> - - - setLoginVisible(false)} - onLoginSuccess={(userData) => login(userData)} - /> - - setProfileVisible(false)} - /> - - -
- - - {children} - - -
-
- -
- Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech -
- - - ); -}; - -export default Layout; diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx deleted file mode 100644 index 057f99c..0000000 --- a/frontend/src/components/LoginModal.jsx +++ /dev/null @@ -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 ( - -
- - } - placeholder="手机号码" - size="large" - /> - - - -
- } - placeholder="验证码" - size="large" - /> - -
-
- - - - - -
- 未注册的手机号验证后将自动创建账号
- 已在小程序绑定的手机号将自动同步身份 -
-
-
- ); -}; - -export default LoginModal; diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx deleted file mode 100644 index 969ae84..0000000 --- a/frontend/src/components/ModelViewer.jsx +++ /dev/null @@ -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 ( -
- 3D 模型加载失败 -
- ); - } - - 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 ; -}; - -const LoadingOverlay = () => { - const { progress, active } = useProgress(); - if (!active) return null; - - return ( -
-
- -
- {progress.toFixed(0)}% Loading -
-
-
- ); -}; - -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 ( -
- -
正在解压 3D 资源...
-
- ); - } - - if (error) { - return ( -
- {error} -
- ); - } - - if (!paths) return null; - - return ( -
- - - - - - - - - - - - - - - - - -
- ); -}; - -export default ModelViewer; diff --git a/frontend/src/components/ParticleBackground.jsx b/frontend/src/components/ParticleBackground.jsx deleted file mode 100644 index 6bb17a1..0000000 --- a/frontend/src/components/ParticleBackground.jsx +++ /dev/null @@ -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 ; -}; - -export default ParticleBackground; diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx deleted file mode 100644 index 24f730d..0000000 --- a/frontend/src/components/ProfileModal.jsx +++ /dev/null @@ -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 ( - -
- -
- } - /> - - - -
-
- - - - -
-
- ); -}; - -export default ProfileModal; diff --git a/frontend/src/components/activity/ActivityCard.jsx b/frontend/src/components/activity/ActivityCard.jsx deleted file mode 100644 index 8d9bb15..0000000 --- a/frontend/src/components/activity/ActivityCard.jsx +++ /dev/null @@ -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 ( - -
- {/* Placeholder Background - Always visible behind the image */} -
- - {activity.title} setIsLoaded(true)} - onError={() => { - setHasError(true); - setIsLoaded(true); - }} - loading="lazy" - /> -
-
- {activity.status || getStatus(activity.start_time)} -
-

{activity.title}

-
- - {formatDate(activity.start_time)} -
-
-
- - ); -}; - -export default ActivityCard; diff --git a/frontend/src/components/activity/ActivityCard.stories.jsx b/frontend/src/components/activity/ActivityCard.stories.jsx deleted file mode 100644 index afd33af..0000000 --- a/frontend/src/components/activity/ActivityCard.stories.jsx +++ /dev/null @@ -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) => ( - -
- -
-
- ), - ], - tags: ['autodocs'], -}; - -const Template = (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', - }, -}; diff --git a/frontend/src/components/activity/ActivityList.jsx b/frontend/src/components/activity/ActivityList.jsx deleted file mode 100644 index 3badb43..0000000 --- a/frontend/src/components/activity/ActivityList.jsx +++ /dev/null @@ -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
Loading activities...
; - if (error) return null; // Or error state - if (!activities || activities.length === 0) return null; - - return ( - -
-

- 近期活动 / EVENTS -

-
- - -
-
- - {/* 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" - */} -
- - - - - - -
- {activities.map((_, idx) => ( - setCurrentIndex(idx)} - /> - ))} -
-
- - {/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */} -
- {activities.map((item, index) => ( - - - - ))} -
-
- ); -}; - -export default ActivityList; diff --git a/frontend/src/components/activity/activity.module.less b/frontend/src/components/activity/activity.module.less deleted file mode 100644 index da7030b..0000000 --- a/frontend/src/components/activity/activity.module.less +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx deleted file mode 100644 index 7180a6a..0000000 --- a/frontend/src/context/AuthContext.jsx +++ /dev/null @@ -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 ( - - {children} - - ); -}; - -export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 922e120..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index b9a1a6d..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -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( - - - , -) diff --git a/frontend/src/pages/AIServices.jsx b/frontend/src/pages/AIServices.jsx deleted file mode 100644 index f466b40..0000000 --- a/frontend/src/pages/AIServices.jsx +++ /dev/null @@ -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 ( -
- -
Loading services...
-
- ); - } - - return ( -
-
- - - AI 全栈<span style={{ color: '#00f0ff', textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>解决方案</span> - - - - 从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。 - -
- - - {services.map((item, index) => ( - - navigate(`/services/${item.id}`)} - style={{ cursor: 'pointer' }} - > -
- {/* HUD 装饰线 */} -
-
-
-
- -
-
- {item.display_icon ? ( - {item.title} - ) : ( -
- )} -
-

{item.title}

-
- -

{item.description}

- -
- {item.features_list && item.features_list.map((feat, i) => ( -
-
- {feat} -
- ))} -
- - -
- - - ))} - - - {/* 动态流程图优化 */} - -
- - - <span className="neon-text-green">服务流程</span> - - - - {[ - { title: '需求分析', icon: , desc: '深度沟通需求' }, - { title: '数据准备', icon: , desc: '高效数据处理' }, - { title: '模型训练', icon: , desc: '高性能算力' }, - { title: '测试验证', icon: , desc: '多维精度测试' }, - { title: '私有化部署', icon: , desc: '全栈落地部署' } - ].map((step, i) => ( - -
- - {step.icon} - - - -
{step.title}
-
{step.desc}
-
- - {/* 连接线 */} - {i < 4 && ( -
- )} -
- - ))} - - - - -
- ); -}; - -export default AIServices; diff --git a/frontend/src/pages/ForumDetail.jsx b/frontend/src/pages/ForumDetail.jsx deleted file mode 100644 index e017747..0000000 --- a/frontend/src/pages/ForumDetail.jsx +++ /dev/null @@ -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\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
Loading...
; - if (!topic) return
Topic not found
; - - const markdownComponents = { - // eslint-disable-next-line no-unused-vars - code({node, inline, className, children, ...props}) { - const match = /language-(\w+)/.exec(className || '') - return !inline && match ? ( - - {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ) - }, - // eslint-disable-next-line no-unused-vars - img({node, ...props}) { - return ( - - ); - } - }; - - return ( -
-
- - - {/* Debug Info: Remove in production */} - {/*
- User ID: {user?.id} ({typeof user?.id})
- Topic Author: {topic.author} ({typeof topic.author})
- Match: {String(topic.author) === String(user?.id) ? 'Yes' : 'No'} -
*/} - - {user && String(topic.author) === String(user.id) && ( - - )} -
- - {/* Topic Content */} - -
- {topic.is_pinned && 置顶} - {topic.product_info && {topic.product_info.name}} - {topic.title} - - - - } /> - {topic.author_info?.nickname} - {topic.is_verified_owner && ( - - - - )} - - - - {new Date(topic.created_at).toLocaleString()} - - - - {topic.view_count} 阅读 - - -
- - - -
- - {topic.content} - -
- - {(() => { - if (topic.media && topic.media.length > 0) { - return topic.media.filter(m => m.media_type === 'video').map((media) => ( -
-
- )); - } - return null; - })()} -
- - {/* Replies List */} -
- - {topic.replies?.length || 0} 条回复 - - - {topic.replies?.map((reply, index) => ( - -
- } /> -
-
- - {reply.author_info?.nickname} - {new Date(reply.created_at).toLocaleString()} - - #{index + 1} -
-
- - {reply.content} - -
-
-
-
- ))} -
- - {/* Reply Form */} - - 发表回复 - {user ? ( - <> -