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![${file.name}](${url})\n`; + + const newContent = content + insertText; + setContent(newContent); + form.setFieldsValue({ content: newContent }); + + message.success('上传成功'); + } catch (error) { + console.error(error); + message.error('上传失败'); + } finally { + setUploading(false); + } + return false; // 阻止默认上传行为 + }; + + const handleSubmit = async (values) => { + setLoading(true); + try { + // 处理关联项目 ID (select value format: "type_id") + const relatedValue = values.related_item; + // Use content state instead of form value to ensure consistency + const payload = { ...values, content: content, media_ids: mediaIds }; + delete payload.related_item; + + if (relatedValue) { + const [type, id] = relatedValue.split('_'); + if (type === 'config') payload.related_product = id; + if (type === 'course') payload.related_course = id; + if (type === 'service') payload.related_service = id; + } else { + // If cleared, set to null + payload.related_product = null; + payload.related_course = null; + payload.related_service = null; + } + + if (isEditMode && topicId) { + await updateTopic(topicId, payload); + message.success('修改成功'); + } else { + await createTopic(payload); + message.success('发布成功'); + } + + form.resetFields(); + if (onSuccess) onSuccess(); + onClose(); + } catch (error) { + console.error(error); + message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误')); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + + +
+ + + + + + + +
+ + +
+
+ + + +
+ + { + setContent(val); + form.setFieldsValue({ content: val }); + }} + height={400} + previewOptions={{ + rehypePlugins: [[rehypeKatex]], + remarkPlugins: [[remarkMath]], + }} + /> +
+
+ + +
+ + +
+
+
+
+ ); +}; + +export default CreateTopicModal; \ No newline at end of file diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx 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 ( + + + +
+
+ navigate('/')} + > + Quant Speed Logo + + + {/* Desktop Menu */} +
+ handleMenuClick(e.key)} + style={{ + background: 'transparent', + borderBottom: 'none', + display: 'flex', + justifyContent: 'flex-end', + minWidth: '400px', + marginRight: '20px' + }} + /> + + {user ? ( +
+ {/* 小程序图标状态 */} + + + +
+ } style={{ marginRight: 8 }} /> + {user.nickname} +
+
+
+ ) : ( + + )} +
+ + + {/* Mobile Menu Button */} +
+
+ + {/* Mobile Drawer Menu */} + 导航菜单} + placement="right" + onClose={() => setMobileMenuOpen(false)} + open={mobileMenuOpen} + styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }} + > +
+ {user ? ( +
+ } + size="large" + style={{ marginBottom: 10, cursor: 'pointer' }} + onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} + /> +
{ setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}> + {user.nickname} +
+ +
+ ) : ( + + )} +
+ handleMenuClick(e.key)} + style={{ background: 'transparent', borderRight: 'none' }} + /> + + + setLoginVisible(false)} + onLoginSuccess={(userData) => login(userData)} + /> + + setProfileVisible(false)} + /> + + +
+ + + {children} + + +
+
+ +
+ Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech +
+ + + ); +}; + +export default Layout; diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx 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" + /> + +
+
+ + + + + +
+ 未注册的手机号验证后将自动创建账号
+ 已在小程序绑定的手机号将自动同步身份 +
+
+
+ ); +}; + +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 ( +
+ +
正在解压 3D 资源...
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!paths) return null; + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ); +}; + +export default ModelViewer; diff --git a/frontend/src/components/ParticleBackground.jsx b/frontend/src/components/ParticleBackground.jsx 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 ( + +
+ +
+ } + /> + + + +
+
+ + + + +
+
+ ); +}; + +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 */} +
+ + {activity.title} setIsLoaded(true)} + onError={() => { + setHasError(true); + setIsLoaded(true); + }} + loading="lazy" + /> +
+
+ {activity.status || getStatus(activity.start_time)} +
+

{activity.title}

+
+ + {formatDate(activity.start_time)} +
+
+
+ + ); +}; + +export default ActivityCard; diff --git a/frontend/src/components/activity/ActivityCard.stories.jsx b/frontend/src/components/activity/ActivityCard.stories.jsx 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 全栈<span style={{ color: '#00f0ff', textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>解决方案</span> + + + + 从数据处理到模型部署,我们为您提供一站式 AI 基础设施服务。 + +
+ + + {services.map((item, index) => ( + + navigate(`/services/${item.id}`)} + style={{ cursor: 'pointer' }} + > +
+ {/* HUD 装饰线 */} +
+
+
+
+ +
+
+ {item.display_icon ? ( + {item.title} + ) : ( +
+ )} +
+

{item.title}

+
+ +

{item.description}

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