diff --git a/frontend/public/big_logo.png b/frontend/public/big_logo.png
new file mode 100644
index 0000000..2242f36
Binary files /dev/null and b/frontend/public/big_logo.png differ
diff --git a/frontend/public/gXEu5E01.svg b/frontend/public/gXEu5E01.svg
new file mode 100644
index 0000000..6768690
--- /dev/null
+++ b/frontend/public/gXEu5E01.svg
@@ -0,0 +1,106 @@
+
+
+
+
+Created by potrace 1.10, written by Peter Selinger 2001-2011
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/liangji_black.png b/frontend/public/liangji_black.png
new file mode 100644
index 0000000..c2396e0
Binary files /dev/null and b/frontend/public/liangji_black.png differ
diff --git a/frontend/public/liangji_logo.svg b/frontend/public/liangji_logo.svg
new file mode 100644
index 0000000..25f8a5d
--- /dev/null
+++ b/frontend/public/liangji_logo.svg
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ QUANT SPEED
+
+
\ No newline at end of file
diff --git a/frontend/public/liangji_white.png b/frontend/public/liangji_white.png
new file mode 100644
index 0000000..ddf0cd6
Binary files /dev/null and b/frontend/public/liangji_white.png differ
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..7ca6634
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,41 @@
+#root {
+ width: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..4714660
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,49 @@
+
+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
new file mode 100644
index 0000000..d083bf1
--- /dev/null
+++ b/frontend/src/animation.js
@@ -0,0 +1,53 @@
+
+// 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
new file mode 100644
index 0000000..5d074ac
--- /dev/null
+++ b/frontend/src/api.js
@@ -0,0 +1,73 @@
+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
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/CreateTopicModal.jsx b/frontend/src/components/CreateTopicModal.jsx
new file mode 100644
index 0000000..6ffd63f
--- /dev/null
+++ b/frontend/src/components/CreateTopicModal.jsx
@@ -0,0 +1,281 @@
+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\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 (
+
+
+
+
+
+
+
+
+ 技术讨论
+ 求助问答
+ 经验分享
+
+
+
+
+
+
+ {paidItems.configs.map(i => (
+ {i.name}
+ ))}
+
+
+ {paidItems.courses.map(i => (
+ {i.title}
+ ))}
+
+
+ {paidItems.services.map(i => (
+ {i.title}
+ ))}
+
+
+
+
+
+
+
+
+
+ } loading={uploading} size="small">
+ 插入图片/视频
+
+
+
+
+
{
+ setContent(val);
+ form.setFieldsValue({ content: val });
+ }}
+ height={400}
+ previewOptions={{
+ rehypePlugins: [[rehypeKatex]],
+ remarkPlugins: [[remarkMath]],
+ }}
+ />
+
+
+
+
+
+ 取消
+
+ {isEditMode ? "保存修改" : "立即发布"}
+
+
+
+
+
+ );
+};
+
+export default CreateTopicModal;
\ No newline at end of file
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000..97b75dd
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -0,0 +1,278 @@
+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 (
+
+
+
+
+
+ {/* 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}
+
+
退出登录
+
+ ) : (
+
{ setLoginVisible(true); setMobileMenuOpen(false); }}>登录 / 注册
+ )}
+
+ handleMenuClick(e.key)}
+ style={{ background: 'transparent', borderRight: 'none' }}
+ />
+
+
+ setLoginVisible(false)}
+ onLoginSuccess={(userData) => login(userData)}
+ />
+
+ setProfileVisible(false)}
+ />
+
+
+
+
+
+
+ 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
new file mode 100644
index 0000000..057f99c
--- /dev/null
+++ b/frontend/src/components/LoginModal.jsx
@@ -0,0 +1,123 @@
+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"
+ />
+ 0}
+ >
+ {countdown > 0 ? `${countdown}s` : '获取验证码'}
+
+
+
+
+
+
+ 登录
+
+
+
+
+ 未注册的手机号验证后将自动创建账号
+ 已在小程序绑定的手机号将自动同步身份
+
+
+
+ );
+};
+
+export default LoginModal;
diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx
new file mode 100644
index 0000000..969ae84
--- /dev/null
+++ b/frontend/src/components/ModelViewer.jsx
@@ -0,0 +1,218 @@
+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 (
+
+ );
+ }
+
+ 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
new file mode 100644
index 0000000..6bb17a1
--- /dev/null
+++ b/frontend/src/components/ParticleBackground.jsx
@@ -0,0 +1,174 @@
+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
new file mode 100644
index 0000000..24f730d
--- /dev/null
+++ b/frontend/src/components/ProfileModal.jsx
@@ -0,0 +1,124 @@
+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 (
+
+
+
+
}
+ />
+
+ : } loading={uploading}>
+ {uploading ? '上传中...' : '更换头像'}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ProfileModal;
diff --git a/frontend/src/components/activity/ActivityCard.jsx b/frontend/src/components/activity/ActivityCard.jsx
new file mode 100644
index 0000000..8d9bb15
--- /dev/null
+++ b/frontend/src/components/activity/ActivityCard.jsx
@@ -0,0 +1,101 @@
+
+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 */}
+
+
+
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
new file mode 100644
index 0000000..afd33af
--- /dev/null
+++ b/frontend/src/components/activity/ActivityCard.stories.jsx
@@ -0,0 +1,67 @@
+
+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
new file mode 100644
index 0000000..3badb43
--- /dev/null
+++ b/frontend/src/components/activity/ActivityList.jsx
@@ -0,0 +1,110 @@
+
+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
new file mode 100644
index 0000000..da7030b
--- /dev/null
+++ b/frontend/src/components/activity/activity.module.less
@@ -0,0 +1,266 @@
+
+@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
new file mode 100644
index 0000000..7180a6a
--- /dev/null
+++ b/frontend/src/context/AuthContext.jsx
@@ -0,0 +1,81 @@
+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
new file mode 100644
index 0000000..922e120
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,59 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Orbitron', 'Roboto', sans-serif; /* 假设引入了科技感字体 */
+ background-color: #050505;
+ color: #fff;
+ overflow-x: hidden;
+}
+
+/* 全局滚动条美化 */
+::-webkit-scrollbar {
+ width: 8px;
+}
+::-webkit-scrollbar-track {
+ background: #000;
+}
+::-webkit-scrollbar-thumb {
+ background: #333;
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #00b96b;
+}
+
+/* 霓虹光效工具类 */
+.neon-text-green {
+ color: #00b96b;
+ text-shadow: 0 0 5px rgba(0, 185, 107, 0.5), 0 0 10px rgba(0, 185, 107, 0.3);
+}
+
+.neon-text-blue {
+ color: #00f0ff;
+ text-shadow: 0 0 5px rgba(0, 240, 255, 0.5), 0 0 10px rgba(0, 240, 255, 0.3);
+}
+
+.neon-border {
+ border: 1px solid rgba(0, 185, 107, 0.3);
+ box-shadow: 0 0 10px rgba(0, 185, 107, 0.1), inset 0 0 10px rgba(0, 185, 107, 0.1);
+}
+
+/* 玻璃拟态 */
+.glass-panel {
+ background: rgba(20, 20, 20, 0.6);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+}
+
+/* 粒子背景容器 */
+#particle-canvas {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: -1;
+ pointer-events: none;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/AIServices.jsx b/frontend/src/pages/AIServices.jsx
new file mode 100644
index 0000000..f466b40
--- /dev/null
+++ b/frontend/src/pages/AIServices.jsx
@@ -0,0 +1,235 @@
+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 全栈解决方案
+
+
+
+ 从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。
+
+
+
+
+ {services.map((item, index) => (
+
+ navigate(`/services/${item.id}`)}
+ style={{ cursor: 'pointer' }}
+ >
+
+ {/* HUD 装饰线 */}
+
+
+
+
+
+
+
+ {item.display_icon ? (
+
+ ) : (
+
+ )}
+
+
{item.title}
+
+
+
{item.description}
+
+
+ {item.features_list && item.features_list.map((feat, i) => (
+
+ ))}
+
+
+
}
+ onClick={(e) => {
+ e.stopPropagation();
+ navigate(`/services/${item.id}`);
+ }}
+ >
+ 了解更多
+
+
+
+
+ ))}
+
+
+ {/* 动态流程图优化 */}
+
+
+
+
+ 服务流程
+
+
+
+ {[
+ { 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
new file mode 100644
index 0000000..e017747
--- /dev/null
+++ b/frontend/src/pages/ForumDetail.jsx
@@ -0,0 +1,372 @@
+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\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 (
+
+
+
}
+ style={{ color: '#fff' }}
+ onClick={() => navigate('/forum')}
+ >
+ 返回列表
+
+
+ {/* 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) && (
+
}
+ onClick={() => setEditModalVisible(true)}
+ >
+ 编辑帖子
+
+ )}
+
+
+ {/* 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 ? (
+ <>
+
+
+
setLoginModalVisible(false)}
+ onLoginSuccess={() => {}}
+ />
+
+ {/* Edit Modal */}
+ {
+ setEditModalVisible(false);
+ // Workaround for scroll issue: Force reload page on close
+ window.location.reload();
+ }}
+ onSuccess={() => {
+ fetchTopic();
+ }}
+ initialValues={topic}
+ isEditMode={true}
+ topicId={topic?.id}
+ />
+
+ );
+};
+
+export default ForumDetail;
\ No newline at end of file
diff --git a/frontend/src/pages/ForumList.jsx b/frontend/src/pages/ForumList.jsx
new file mode 100644
index 0000000..b70769d
--- /dev/null
+++ b/frontend/src/pages/ForumList.jsx
@@ -0,0 +1,328 @@
+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 ;
+ case 'share': return ;
+ case 'notice': return ;
+ default: return ;
+ }
+ };
+
+ 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 (
+
+ {/* Hero Section */}
+
+
+
+ Quant Speed Developer Community
+
+
+ 技术交流 · 硬件开发 · 官方支持 · 量迹生态
+
+
+
+
+ }
+ 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)}
+ />
+ }
+ onClick={handleCreateClick}
+ style={{ height: 'auto', borderRadius: 8 }}
+ >
+ 发布新帖
+
+
+
+
+ {/* Content Section */}
+
+
+
+
+ (
+
+ navigate(`/forum/${item.id}`)}
+ >
+
+
+
+ {item.is_pinned && }>置顶}
+
+ {getCategoryLabel(item.category)}
+
+ {item.is_verified_owner && (
+
+ } color="#00b96b" style={{ margin: 0 }}>认证用户
+
+ )}
+
+ {item.title}
+
+
+
+
+ {item.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')} {/* Simple markdown strip */}
+
+
+ {item.content.match(/!\[.*?\]\((.*?)\)/) && (
+
+
+
+ )}
+
+
+
+ } size="small" />
+
+ {item.author_info?.nickname || '匿名用户'}
+
+ {item.author_info?.is_star && (
+
+
+
+ )}
+
+ •
+ {new Date(item.created_at).toLocaleDateString()}
+ {item.product_info && (
+ {item.product_info.name}
+ )}
+
+
+
+
+
+
+
{item.replies?.length || 0}
+
+
+
+
{item.view_count || 0}
+
+
+
+
+
+ )}
+ locale={{ emptyText: 暂无帖子,来发布第一个吧!
}}
+ />
+
+
+
+ 技术专家榜 }
+ 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)' }}
+ >
+
+ {starUsers.length > 0 ? (
+ starUsers.map(u => (
+
+
} />
+
+
+ {u.nickname}
+
+
{u.title || '技术专家'}
+
+
+ ))
+ ) : (
+
暂无上榜专家
+ )}
+
+
+
+ 社区公告 }
+ 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)' }}
+ >
+ (
+
+ {item.display_image_url && (
+
+
+
+ )}
+
+ {item.link_url ? (
+
{item.title}
+ ) : (
+
{item.title}
+ )}
+
+
+ {item.content}
+
+
+ )}
+ locale={{ emptyText: 暂无公告
}}
+ />
+
+
+
+
+
+
setCreateModalVisible(false)}
+ onSuccess={() => fetchTopics(searchText, category)}
+ />
+
+ setLoginModalVisible(false)}
+ onLoginSuccess={() => {
+ setCreateModalVisible(true);
+ }}
+ />
+
+ );
+};
+
+export default ForumList;
diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css
new file mode 100644
index 0000000..095bb0f
--- /dev/null
+++ b/frontend/src/pages/Home.css
@@ -0,0 +1,78 @@
+.tech-card {
+ background: rgba(255, 255, 255, 0.05) !important;
+ border: 1px solid #303030 !important;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ box-shadow: none !important; /* 强制移除默认阴影 */
+ overflow: hidden; /* 确保子元素不会溢出产生黑边 */
+ outline: none;
+}
+
+.tech-card:hover {
+ border-color: #00b96b !important;
+ box-shadow: 0 0 20px rgba(0, 185, 107, 0.4) !important; /* 增强悬停发光 */
+ transform: translateY(-5px);
+}
+
+.tech-card .ant-card-body {
+ border-top: none !important;
+ box-shadow: none !important;
+}
+
+.tech-card-title {
+ color: #fff;
+ font-size: 18px;
+ font-weight: bold;
+ margin-bottom: 10px;
+}
+
+.tech-price {
+ color: #00b96b;
+ font-size: 20px;
+ font-weight: bold;
+}
+
+.product-scroll-container {
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ padding: 30px 20px; /* 增加左右内边距,为悬停缩放和投影留出空间 */
+ margin: 0 -20px; /* 使用负外边距抵消内边距,使滚动条能延伸到版心边缘 */
+ width: calc(100% + 40px);
+}
+
+/* 自定义滚动条 */
+.product-scroll-container::-webkit-scrollbar {
+ height: 6px;
+}
+
+.product-scroll-container::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+ margin: 0 20px; /* 让滚动条轨道在版心内显示 */
+}
+
+.product-scroll-container::-webkit-scrollbar-thumb {
+ background: rgba(0, 185, 107, 0.2);
+ border-radius: 3px;
+ transition: all 0.3s;
+}
+
+.product-scroll-container::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 185, 107, 0.5);
+}
+
+/* 布局对齐 */
+.product-scroll-container .ant-row {
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ padding: 0;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+}
+
+.product-scroll-container .ant-col {
+ flex: 0 0 320px;
+ padding: 0 12px;
+}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000..458adcb
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,193 @@
+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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {typedText}
+ {!isTypingComplete && | }
+
+
+
+ 量迹 AI 硬件为您提供最强大的边缘计算能力,搭载最新一代神经处理单元,赋能您的每一个创意。
+
+
+
+
+
+
+
+
+ {products.map((product, index) => (
+
+
+
+ {product.static_image_url ? (
+
+ ) : (
+
+
+
+ )}
+
+ }
+ onClick={() => navigate(`/product/${product.id}`)}
+ >
+
{product.name}
+
+ {product.description}
+
+
+ {product.chip_type}
+ {product.has_camera && Camera }
+ {product.has_microphone && Mic }
+
+
+
¥{product.price}
+
} style={{ background: '#00b96b', borderColor: '#00b96b' }} />
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Home;
diff --git a/frontend/src/pages/MyOrders.jsx b/frontend/src/pages/MyOrders.jsx
new file mode 100644
index 0000000..24318e5
--- /dev/null
+++ b/frontend/src/pages/MyOrders.jsx
@@ -0,0 +1,342 @@
+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 } color="success">已支付;
+ case 'pending': return } color="warning">待支付;
+ case 'shipped': return } color="processing">已发货;
+ case 'cancelled': return } color="default">已取消;
+ default: return {status} ;
+ }
+ };
+
+ return (
+
+
+
+
+
我的订单
+ Secure Order Verification System
+
+
+ {!user ? (
+
+ 请先登录以查看您的订单
+ setLoginVisible(true)}>立即登录
+
+ ) : (
+
+
+ 当前登录用户: {user.nickname}
+ }
+ >
+ 刷新
+
+
+
+ 我的订单,
+ children: (
+ (
+
+ showDetail(order)}
+ title={订单号: {order.id} {getStatusTag(order.status)} }
+ 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' }}
+ >
+
+
+ {order.total_price} 元
+ {new Date(order.created_at).toLocaleString()}
+
+
+
+
+ {order.config_image ? (
+
+ ) : (
+
+
+
+ )}
+
+
{order.config_name || `商品 ID: ${order.config}`}
+
数量: x{order.quantity}
+
+
+
+
+ {(order.courier_name || order.tracking_number) && (
+
+
+
+
+ 物流信息
+
+
+
+ 快递公司:
+ {order.courier_name || '未知'}
+
+
+
快递单号:
+ {order.tracking_number ? (
+
e.stopPropagation()}>
+
+ {order.tracking_number}
+
+
+ ) : (
+
暂无单号
+ )}
+
+
+
+ )}
+
+
+
+ )}
+ locale={{ emptyText: 暂无订单信息
}}
+ />
+ )
+ },
+ {
+ key: '2',
+ label: 我的活动 ,
+ children: (
+ {
+ const activity = item.activity_info || item.activity || item;
+ return (
+
+ navigate(`/activity/${activity.id}`)}
+ cover={
+
+
+
+ }
+ 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' }}
+ >
+
+
{activity.title}
+
+
+
+
+ {new Date(activity.start_time).toLocaleDateString()}
+
+
+
+
+
+
+ {activity.location || '线上活动'}
+
+
+
+
+ {activity.status || '已报名'}
+ 查看详情
+
+
+
+
+ );
+ }}
+ locale={{ emptyText: 暂无活动报名
}}
+ />
+ )
+ }
+ ]} />
+
+ )}
+
+
订单详情}
+ open={modalVisible}
+ onCancel={() => setModalVisible(false)}
+ footer={[
+ setModalVisible(false)}>
+ 关闭
+
+ ]}
+ width={600}
+ centered
+ >
+ {currentOrder && (
+
+
+ {currentOrder.id}
+
+ {currentOrder.config_name}
+ {new Date(currentOrder.created_at).toLocaleString()}
+ {new Date(currentOrder.updated_at).toLocaleString()}
+ {getStatusTag(currentOrder.status)}
+
+ ¥{currentOrder.total_price}
+
+
+
+
+ {currentOrder.customer_name}
+ {currentOrder.phone_number}
+ {currentOrder.shipping_address}
+
+
+
+ {currentOrder.salesperson_name && (
+
+
+ {currentOrder.salesperson_name}
+ {currentOrder.salesperson_code && {currentOrder.salesperson_code} }
+
+
+ )}
+
+ {(currentOrder.status === 'shipped' || currentOrder.courier_name) && (
+ <>
+ {currentOrder.courier_name || '未知'}
+
+ {currentOrder.tracking_number ? (
+
+ {currentOrder.tracking_number}
+
+ ) : '暂无单号'}
+
+ >
+ )}
+
+ )}
+
+
+
setLoginVisible(false)}
+ onLoginSuccess={(userData) => {
+ login(userData);
+ if (userData.phone_number) {
+ handleQueryOrders(userData.phone_number);
+ }
+ }}
+ />
+
+
+ );
+};
+
+export default MyOrders;
diff --git a/frontend/src/pages/Payment.css b/frontend/src/pages/Payment.css
new file mode 100644
index 0000000..2da9ed7
--- /dev/null
+++ b/frontend/src/pages/Payment.css
@@ -0,0 +1,52 @@
+.payment-container {
+ max-width: 600px;
+ margin: 50px auto;
+ padding: 40px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid #303030;
+ border-radius: 12px;
+ text-align: center;
+}
+
+.payment-title {
+ color: #fff;
+ font-size: 28px;
+ margin-bottom: 30px;
+}
+
+.payment-amount {
+ font-size: 48px;
+ color: #00b96b;
+ font-weight: bold;
+ margin: 20px 0;
+}
+
+.payment-info {
+ text-align: left;
+ background: rgba(0,0,0,0.3);
+ padding: 20px;
+ border-radius: 8px;
+ margin-bottom: 30px;
+ color: #ccc;
+}
+
+.payment-method {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.payment-method-item {
+ border: 1px solid #444;
+ padding: 10px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.3s;
+ color: #fff;
+}
+
+.payment-method-item.active {
+ border-color: #00b96b;
+ background: rgba(0, 185, 107, 0.1);
+}
diff --git a/frontend/src/pages/Payment.jsx b/frontend/src/pages/Payment.jsx
new file mode 100644
index 0000000..1286aaa
--- /dev/null
+++ b/frontend/src/pages/Payment.jsx
@@ -0,0 +1,206 @@
+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 ;
+
+ if (paySuccess) {
+ return (
+
+ }
+ title={支付成功 }
+ subTitle={订单 {currentOrderId} 已完成支付,我们将尽快为您发货。 }
+ extra={[
+ navigate('/')}>
+ 返回首页
+ ,
+ ]}
+ />
+
+ );
+ }
+
+ return (
+
+
收银台
+
+ {order ? (
+ <>
+
¥{order.total_price}
+
+
订单编号: {order.id}
+
商品名称: {order.config_name || 'AI 硬件设备'}
+
收货人: {order.customer_name}
+
+ >
+ ) : (
+
+
订单 ID: {currentOrderId}
+
无法加载详情,但您可以尝试支付。
+
+ )}
+
+
选择支付方式:
+
+
setPaymentMethod('wechat')}
+ >
+
+ 微信支付
+
+
setPaymentMethod('alipay')}
+ >
+
+ 支付宝
+
+
+
+ {paying && (
+
+ {codeUrl ? (
+ <>
+
+
+
+
请使用微信扫码支付
+
支付完成后将自动跳转
+ >
+ ) : (
+
+ )}
+
+ )}
+
+ {!paying && (
+
+ 立即支付
+
+ )}
+
+ );
+};
+
+export default Payment;
diff --git a/frontend/src/pages/ProductDetail.css b/frontend/src/pages/ProductDetail.css
new file mode 100644
index 0000000..7392d3d
--- /dev/null
+++ b/frontend/src/pages/ProductDetail.css
@@ -0,0 +1,33 @@
+.product-detail-container {
+ color: #fff;
+}
+
+.feature-section {
+ padding: 60px 0;
+ border-bottom: 1px solid #303030;
+ text-align: center;
+}
+
+.feature-title {
+ font-size: 32px;
+ font-weight: bold;
+ margin-bottom: 20px;
+ color: #00b96b;
+}
+
+.feature-desc {
+ font-size: 18px;
+ color: #888;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.spec-tag {
+ background: rgba(0, 185, 107, 0.1);
+ border: 1px solid #00b96b;
+ color: #00b96b;
+ padding: 5px 15px;
+ border-radius: 4px;
+ margin-right: 10px;
+ display: inline-block;
+}
diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx
new file mode 100644
index 0000000..bfd2634
--- /dev/null
+++ b/frontend/src/pages/ProductDetail.jsx
@@ -0,0 +1,308 @@
+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 ;
+ }
+
+ const iconProps = { style: { fontSize: 60, color: '#00b96b', marginBottom: 20 } };
+
+ switch(feature.icon_name) {
+ case 'SafetyCertificate':
+ return ;
+ case 'Eye':
+ return ;
+ case 'Thunderbolt':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ if (loading) return
;
+ if (!product) return null;
+
+ return (
+
+ {/* Hero Section */}
+
+
+
+ {modelPaths ? (
+
+ ) : product.static_image_url ? (
+
+ ) : (
+
+ )}
+
+
+
+ {product.name}
+ {product.description}
+
+
+ {product.chip_type}
+ {product.has_camera && 高清摄像头 }
+ {product.has_microphone && 阵列麦克风 }
+
+
+
+
+
+
+
+ {product.stock < 5 && product.stock > 0 && (
+
+ )}
+
+ {product.stock === 0 && (
+
+ )}
+
+ }
+ onClick={() => setIsModalOpen(true)}
+ disabled={product.stock === 0}
+ style={{ height: 50, padding: '0 40px', fontSize: 18 }}
+ >
+ {product.stock === 0 ? '暂时缺货' : '立即购买'}
+
+
+
+
+ {/* Feature Section */}
+
+ {product.features && product.features.length > 0 ? (
+ product.features.map((feature, index) => (
+
+ {renderIcon(feature)}
+
{feature.title}
+
{feature.description}
+
+ ))
+ ) : (
+ // Fallback content if no features are configured
+ <>
+
+
+
工业级安全标准
+
+ 采用军工级加密芯片,保障您的数据隐私安全。无论是边缘计算还是云端同步,全程加密传输,让 AI 应用无后顾之忧。
+
+
+
+
+
+
超清视觉感知
+
+ 搭载 4K 高清摄像头与 AI 视觉算法,实时捕捉每一个细节。支持人脸识别、物体检测、姿态分析等多种视觉任务。
+
+
+
+
+
+
极致性能释放
+
+ {product.chip_type} 强劲核心,提供高达 XX TOPS 的算力支持。低功耗设计,满足 24 小时全天候运行需求。
+
+
+ >
+ )}
+
+ {product.display_detail_image ? (
+
+
+
+ ) : (
+
+ 产品详情长图展示区域 (请在后台配置)
+
+ )}
+
+
+ {/* Order Modal */}
+
setIsModalOpen(false)}
+ footer={null}
+ destroyOnHidden
+ >
+
+
+ 快递配送
+ 线下自提
+
+
+
+
+
+
+
+
+
+
+
+ prevValues.delivery_method !== currentValues.delivery_method}
+ >
+ {({ getFieldValue }) =>
+ getFieldValue('delivery_method') === 'shipping' ? (
+
+
+
+ ) : (
+
+
自提地址:昆明市云纺国际商厦B座1406
+
请在工作日 9:00 - 18:00 期间前往提货
+
+ )
+ }
+
+
+
+ setIsModalOpen(false)}>取消
+ 提交订单
+
+
+
+
+ );
+};
+
+export default ProductDetail;
diff --git a/frontend/src/pages/ServiceDetail.jsx b/frontend/src/pages/ServiceDetail.jsx
new file mode 100644
index 0000000..66e69f9
--- /dev/null
+++ b/frontend/src/pages/ServiceDetail.jsx
@@ -0,0 +1,265 @@
+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 (
+
+ );
+ }
+
+ if (!service) {
+ return (
+
+
+ navigate('/services')} style={{ marginTop: 20 }}>
+ Return to Services
+
+
+ );
+ }
+
+ return (
+
+
}
+ style={{ color: '#fff', marginBottom: 20 }}
+ onClick={() => navigate('/services')}
+ >
+ 返回服务列表
+
+
+
+
+
+
+
+ {service.title}
+
+
+ {service.description}
+
+
+
+
+
+ 服务详情
+
+
+ 交付周期}>
+ {service.delivery_time || '待沟通'}
+
+ 交付内容}>
+ {service.delivery_content || '根据需求定制'}
+
+
+
+
+
+ {service.display_detail_image ? (
+
+
+
+ ) : (
+
+ 暂无详情图片
+
+ )}
+
+
+
+
+
+
服务报价
+
+ ¥{service.price}
+ / {service.unit} 起
+
+
+
+ {service.features_list && service.features_list.map((feat, i) => (
+
+ {feat}
+
+ ))}
+
+
+
}
+ style={{
+ height: 50,
+ background: service.color,
+ borderColor: service.color,
+ color: '#000',
+ fontWeight: 'bold'
+ }}
+ onClick={() => setIsModalOpen(true)}
+ >
+ 立即咨询 / 购买
+
+
+ * 具体价格可能因需求复杂度而异,提交需求后我们将提供详细报价单
+
+
+
+
+
+
+
+ {/* Purchase Modal */}
+
setIsModalOpen(false)}
+ footer={null}
+ destroyOnHidden
+ >
+ 请填写您的联系方式和需求,我们的技术顾问将在 24 小时内与您联系。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsModalOpen(false)}>取消
+ 提交需求
+
+
+
+
+ );
+};
+
+export default ServiceDetail;
diff --git a/frontend/src/pages/VCCourseDetail.jsx b/frontend/src/pages/VCCourseDetail.jsx
new file mode 100644
index 0000000..da3d945
--- /dev/null
+++ b/frontend/src/pages/VCCourseDetail.jsx
@@ -0,0 +1,286 @@
+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 (
+
+ );
+ }
+
+ if (!course) {
+ return (
+
+
+ navigate('/courses')} style={{ marginTop: 20 }}>
+ Return to Courses
+
+
+ );
+ }
+
+ return (
+
+
}
+ style={{ color: '#fff', marginBottom: 20 }}
+ onClick={() => navigate('/courses')}
+ >
+ 返回课程列表
+
+
+
+
+
+
+
+ {course.tag && {course.tag} }
+
+ {course.course_type_display || (course.course_type === 'hardware' ? '硬件课程' : '软件课程')}
+
+
+
+ {course.title}
+
+
+ {course.description}
+
+
+
+
+
+ 课程信息
+
+
+ 讲师}>
+
+ {course.instructor_avatar_url && (
+
+ )}
+
{course.instructor}
+ {course.instructor_title && (
+
+ {course.instructor_title}
+
+ )}
+
+
+ 时长}>
+ {course.duration}
+
+ 课时}>
+ {course.lesson_count} 课时
+
+
+
+ {/* 讲师简介 */}
+ {course.instructor_desc && (
+
+ 讲师简介:
+ {course.instructor_desc}
+
+ )}
+
+
+ {/* 课程详细内容区域 */}
+ {course.content && (
+
+
课程大纲与详情
+
+ {course.content}
+
+
+ )}
+
+
+ {course.display_detail_image ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
报名咨询
+
+
+ {parseFloat(course.price) > 0 ? (
+ <>
+ ¥{course.price}
+ >
+ ) : (
+ 免费咨询
+ )}
+
+
+
}
+ style={{
+ height: 50,
+ background: '#00f0ff',
+ borderColor: '#00f0ff',
+ color: '#000',
+ fontWeight: 'bold',
+ fontSize: '16px'
+ }}
+ onClick={() => setIsModalOpen(true)}
+ >
+ 立即报名 / 咨询
+
+
+ * 提交后我们的顾问将尽快与您联系确认
+
+
+
+
+
+
+
+ {/* Enroll Modal */}
+
setIsModalOpen(false)}
+ footer={null}
+ destroyOnHidden
+ >
+ 请填写您的联系方式,我们将为您安排课程顾问。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsModalOpen(false)}>取消
+ 提交报名
+
+
+
+
+ );
+};
+
+export default VCCourseDetail;
\ No newline at end of file
diff --git a/frontend/src/pages/VCCourses.jsx b/frontend/src/pages/VCCourses.jsx
new file mode 100644
index 0000000..cd48a70
--- /dev/null
+++ b/frontend/src/pages/VCCourses.jsx
@@ -0,0 +1,129 @@
+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
;
+
+ return (
+
+
+
+ VC CODING COURSES
+
+
+ 探索 VB Coding 软件与硬件课程,开启您的编程之旅。
+
+
+
+ {courses.length === 0 ? (
+
+ 暂无课程内容} />
+
+ ) : (
+
+ {courses.map((item, index) => (
+
+ navigate(`/courses/${item.id}`)}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+ {item.display_cover_image ? (
+
+ ) : (
+
+ )}
+
+ {item.tag && (
+ {item.tag}
+ )}
+
+ {item.course_type_display || (item.course_type === 'hardware' ? '硬件课程' : '软件课程')}
+
+
+
+
+
{item.title}
+
+ {item.instructor}
+ {item.duration}
+ {item.lesson_count} 课时
+
+
{item.description}
+
+ 开始学习
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 装饰性背景 */}
+
+
+
+
+
+ );
+};
+
+export default VCCourses;
diff --git a/frontend/src/pages/activity/Detail.jsx b/frontend/src/pages/activity/Detail.jsx
new file mode 100644
index 0000000..b825744
--- /dev/null
+++ b/frontend/src/pages/activity/Detail.jsx
@@ -0,0 +1,334 @@
+
+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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ navigate(-1)}>
+ 返回列表
+
+ ]}
+ />
+
+ );
+ }
+
+ const cleanUrl = (url) => {
+ if (!url) return '';
+ return url.replace(/[`\s]/g, '');
+ };
+
+ return (
+
+ {/* Sticky Header */}
+
+ navigate(-1)}
+ style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
+ >
+
+
+
+ {activity.title}
+
+
+
+
+
+
+ {/* Hero Image with LayoutId for shared transition */}
+
+
+ {/* Content */}
+
+
+
{activity.title}
+
+
+
+
+ {activity.start_time ? new Date(activity.start_time).toLocaleDateString() : 'TBD'}
+
+
+
+ {activity.start_time ? new Date(activity.start_time).toLocaleTimeString() : 'TBD'}
+
+
+
+ {activity.location || '线上活动'}
+
+
+
+ {activity.current_signups || 0} / {activity.max_participants} 已报名
+
+
+
+
+
+ {activity.status || (new Date() < new Date(activity.start_time) ? '报名中' : '已结束')}
+
+
+
+
+
+
活动详情
+
+
+ {String(children).replace(/\n$/, '')}
+
+ ) : (
+
+ {children}
+
+ )
+ }
+ }}
+ >
+ {activity.description || activity.content || '暂无详情描述'}
+
+
+
+
+
+ {/* Fixed Footer */}
+
+
+ 距离报名截止
+
+ {/* Simple countdown placeholder */}
+ 3天 12小时
+
+
+
+ {signUpMutation.isPending ? '提交中...' : activity.is_signed_up ? '已报名' : '立即报名'}
+
+
+
+ setLoginVisible(false)}
+ onLoginSuccess={(userData) => {
+ login(userData);
+ // Auto trigger signup after login if needed, or just let user click again
+ }}
+ />
+
+ setSignupFormVisible(false)}
+ onOk={form.submit}
+ confirmLoading={signUpMutation.isPending}
+ destroyOnHidden
+ >
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default ActivityDetail;
diff --git a/frontend/src/theme.module.less b/frontend/src/theme.module.less
new file mode 100644
index 0000000..dd7bd4a
--- /dev/null
+++ b/frontend/src/theme.module.less
@@ -0,0 +1,69 @@
+
+/* 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;
+ }
+}