new
This commit is contained in:
@@ -10,6 +10,8 @@ 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 'antd/dist/reset.css';
|
||||
import './App.css';
|
||||
|
||||
@@ -24,6 +26,8 @@ function App() {
|
||||
<Route path="/services/:id" element={<ServiceDetail />} />
|
||||
<Route path="/courses" element={<VCCourses />} />
|
||||
<Route path="/courses/:id" element={<VCCourseDetail />} />
|
||||
<Route path="/forum" element={<ForumList />} />
|
||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
||||
<Route path="/my-orders" element={<MyOrders />} />
|
||||
<Route path="/product/:id" element={<ProductDetail />} />
|
||||
<Route path="/payment/:orderId" element={<Payment />} />
|
||||
|
||||
@@ -47,4 +47,18 @@ export const getUserInfo = () => {
|
||||
return api.post('/wechat/update/', {});
|
||||
};
|
||||
|
||||
// Community / Forum API
|
||||
export const getTopics = (params) => api.get('/topics/', { params });
|
||||
export const getTopicDetail = (id) => api.get(`/topics/${id}/`);
|
||||
export const createTopic = (data) => api.post('/topics/', data);
|
||||
export const getReplies = (params) => api.get('/replies/', { params });
|
||||
export const createReply = (data) => api.post('/replies/', data);
|
||||
export const uploadMedia = (data) => {
|
||||
return api.post('/media/', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
77
frontend/src/components/CreateTopicModal.jsx
Normal file
77
frontend/src/components/CreateTopicModal.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message, Upload } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import { createTopic } from '../api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createTopic(values);
|
||||
message.success('发布成功');
|
||||
form.resetFields();
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('发布失败: ' + (error.response?.data?.detail || '网络错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="发布新帖"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
style={{ marginTop: 20 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
|
||||
>
|
||||
<Input placeholder="请输入清晰的问题或讨论标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="内容"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="请详细描述您的问题,支持 Markdown 格式"
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
立即发布
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTopicModal;
|
||||
@@ -1,6 +1,6 @@
|
||||
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 } from '@ant-design/icons';
|
||||
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';
|
||||
@@ -59,6 +59,11 @@ const Layout = ({ children }) => {
|
||||
icon: <EyeOutlined />,
|
||||
label: 'VC 课程',
|
||||
},
|
||||
{
|
||||
key: '/forum',
|
||||
icon: <TeamOutlined />,
|
||||
label: '技术论坛',
|
||||
},
|
||||
{
|
||||
key: '/my-orders',
|
||||
icon: <SearchOutlined />,
|
||||
|
||||
205
frontend/src/pages/ForumDetail.jsx
Normal file
205
frontend/src/pages/ForumDetail.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Breadcrumb, Tooltip } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, LikeOutlined } from '@ant-design/icons';
|
||||
import { getTopicDetail, createReply } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
|
||||
const { Title, Text, Paragraph } = 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);
|
||||
|
||||
const fetchTopic = async () => {
|
||||
try {
|
||||
const res = await getTopicDetail(id);
|
||||
setTopic(res.data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopic();
|
||||
}, [id]);
|
||||
|
||||
const handleSubmitReply = async () => {
|
||||
if (!user) {
|
||||
setLoginModalVisible(true);
|
||||
return;
|
||||
}
|
||||
if (!replyContent.trim()) {
|
||||
message.warning('请输入回复内容');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createReply({
|
||||
topic: id,
|
||||
content: replyContent
|
||||
});
|
||||
message.success('回复成功');
|
||||
setReplyContent('');
|
||||
fetchTopic(); // Refresh to show new reply
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('回复失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Loading...</div>;
|
||||
if (!topic) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Topic not found</div>;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', minHeight: '100vh', maxWidth: 1000, margin: '0 auto' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
style={{ color: '#fff', marginBottom: 20 }}
|
||||
onClick={() => navigate('/forum')}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
{/* Topic Content */}
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
marginBottom: 30
|
||||
}}
|
||||
bodyStyle={{ padding: '30px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{topic.is_pinned && <Tag color="red" style={{ marginRight: 10 }}>置顶</Tag>}
|
||||
{topic.product_info && <Tag color="blue">{topic.product_info.name}</Tag>}
|
||||
<Title level={2} style={{ color: '#fff', margin: '10px 0' }}>{topic.title}</Title>
|
||||
|
||||
<Space size="large" style={{ color: '#888', marginTop: 10 }}>
|
||||
<Space>
|
||||
<Avatar src={topic.author_info?.avatar_url} icon={<UserOutlined />} />
|
||||
<span style={{ color: '#ccc' }}>{topic.author_info?.nickname}</span>
|
||||
{topic.is_verified_owner && (
|
||||
<Tooltip title="已验证购买过相关产品">
|
||||
<CheckCircleFilled style={{ color: '#00b96b' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>{new Date(topic.created_at).toLocaleString()}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<EyeOutlined />
|
||||
<span>{topic.view_count} 阅读</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
|
||||
<div style={{
|
||||
color: '#ddd',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.8,
|
||||
minHeight: 200,
|
||||
whiteSpace: 'pre-wrap' // Preserve formatting
|
||||
}}>
|
||||
{topic.content}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Replies List */}
|
||||
<div style={{ marginBottom: 30 }}>
|
||||
<Title level={4} style={{ color: '#fff', marginBottom: 20 }}>
|
||||
{topic.replies?.length || 0} 条回复
|
||||
</Title>
|
||||
|
||||
{topic.replies?.map((reply, index) => (
|
||||
<Card
|
||||
key={reply.id}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: 'none',
|
||||
marginBottom: 16,
|
||||
borderRadius: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Avatar src={reply.author_info?.avatar_url} icon={<UserOutlined />} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Text style={{ color: '#aaa', fontWeight: 'bold' }}>{reply.author_info?.nickname}</Text>
|
||||
<Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text>
|
||||
</Space>
|
||||
<Text style={{ color: '#444' }}>#{index + 1}</Text>
|
||||
</div>
|
||||
<div style={{ color: '#eee', whiteSpace: 'pre-wrap' }}>{reply.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ color: '#fff', marginBottom: 16 }}>发表回复</Title>
|
||||
{user ? (
|
||||
<>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={replyContent}
|
||||
onChange={e => setReplyContent(e.target.value)}
|
||||
placeholder="友善回复,分享你的见解..."
|
||||
style={{ marginBottom: 16, background: '#111', border: '1px solid #333', color: '#fff' }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={handleSubmitReply} loading={submitting}>
|
||||
提交回复
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<Text style={{ color: '#888' }}>登录后参与讨论</Text>
|
||||
<br/>
|
||||
<Button type="primary" style={{ marginTop: 10 }} onClick={() => setLoginModalVisible(true)}>
|
||||
立即登录
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<LoginModal
|
||||
visible={loginModalVisible}
|
||||
onClose={() => setLoginModalVisible(false)}
|
||||
onLoginSuccess={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumDetail;
|
||||
190
frontend/src/pages/ForumList.jsx
Normal file
190
frontend/src/pages/ForumList.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip } from 'antd';
|
||||
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getTopics } 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 [searchText, setSearchText] = useState('');
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [loginModalVisible, setLoginModalVisible] = useState(false);
|
||||
|
||||
const fetchTopics = async (search = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (search) params.search = search;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopics();
|
||||
}, []);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
setSearchText(value);
|
||||
fetchTopics(value);
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
if (!user) {
|
||||
setLoginModalVisible(true);
|
||||
return;
|
||||
}
|
||||
setCreateModalVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', paddingBottom: 60 }}>
|
||||
{/* Hero Section */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '80px 20px 40px',
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,185,107,0.1) 100%)'
|
||||
}}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Title level={1} style={{ color: '#fff', fontFamily: "'Orbitron', sans-serif", marginBottom: 10 }}>
|
||||
<span style={{ color: '#00b96b' }}>Quant Speed</span> Developer Community
|
||||
</Title>
|
||||
<Text style={{ color: '#888', fontSize: 18, maxWidth: 600, display: 'block', margin: '0 auto 30px' }}>
|
||||
技术交流 · 硬件开发 · 官方支持
|
||||
</Text>
|
||||
</motion.div>
|
||||
|
||||
<div style={{ maxWidth: 600, margin: '0 auto', display: 'flex', gap: 10 }}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="搜索感兴趣的话题..."
|
||||
prefix={<SearchOutlined style={{ color: '#666' }} />}
|
||||
style={{ borderRadius: 8, background: 'rgba(255,255,255,0.1)', border: '1px solid #333', color: '#fff' }}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateClick}
|
||||
style={{ height: 'auto', borderRadius: 8 }}
|
||||
>
|
||||
发布新帖
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '0 20px' }}>
|
||||
<List
|
||||
loading={loading}
|
||||
itemLayout="vertical"
|
||||
dataSource={topics}
|
||||
renderItem={(item) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'rgba(20,20,20,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
onClick={() => navigate(`/forum/${item.id}`)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{item.is_pinned && <Tag color="red">置顶</Tag>}
|
||||
{item.is_verified_owner && (
|
||||
<Tooltip title="已验证购买过相关产品">
|
||||
<Tag icon={<CheckCircleFilled />} color="#00b96b">认证用户</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text style={{ color: '#fff', fontSize: 18, fontWeight: 'bold', cursor: 'pointer' }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
||||
>
|
||||
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
||||
</Paragraph>
|
||||
|
||||
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
|
||||
<Space>
|
||||
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
|
||||
<Text style={{ color: '#888' }}>{item.author_info?.nickname || '匿名用户'}</Text>
|
||||
</Space>
|
||||
<span>•</span>
|
||||
<span>{new Date(item.created_at).toLocaleDateString()}</span>
|
||||
{item.product_info && (
|
||||
<Tag color="blue" style={{ marginLeft: 8 }}>{item.product_info.name}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, minWidth: 80 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<MessageOutlined style={{ fontSize: 16, color: '#00b96b' }} />
|
||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>{item.replies?.length || 0}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 5 }}>
|
||||
<EyeOutlined style={{ fontSize: 16, color: '#666' }} />
|
||||
<div style={{ color: '#888', fontSize: 12 }}>{item.view_count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
locale={{ emptyText: <div style={{ color: '#666', padding: 40 }}>暂无帖子,来发布第一个吧!</div> }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateTopicModal
|
||||
visible={createModalVisible}
|
||||
onClose={() => setCreateModalVisible(false)}
|
||||
onSuccess={() => fetchTopics(searchText)}
|
||||
/>
|
||||
|
||||
<LoginModal
|
||||
visible={loginModalVisible}
|
||||
onClose={() => setLoginModalVisible(false)}
|
||||
onLoginSuccess={() => {
|
||||
// 登录成功后自动打开发布框
|
||||
setCreateModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumList;
|
||||
Reference in New Issue
Block a user