This commit is contained in:
jeremygan2021
2026-02-12 14:20:03 +08:00
parent ba78470052
commit f00cc9a28e
14 changed files with 553 additions and 3 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-12 06:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0004_activity_banner_url_alter_activity_banner'),
]
operations = [
migrations.AddField(
model_name='topic',
name='category',
field=models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类'),
),
]

View File

@@ -71,6 +71,15 @@ class Topic(models.Model):
论坛帖子/主题
"""
title = models.CharField(max_length=200, verbose_name="标题")
CATEGORY_CHOICES = (
('discussion', '技术讨论'),
('help', '求助问答'),
('share', '经验分享'),
('notice', '官方公告'),
)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类")
content = models.TextField(verbose_name="内容", help_text="支持Markdown格式支持插入图片")
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者")

Binary file not shown.

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-02-12 06:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0026_wechatuser_phone_number'),
]
operations = [
migrations.AddField(
model_name='wechatuser',
name='is_star',
field=models.BooleanField(default=False, verbose_name='是否明星技术用户'),
),
migrations.AddField(
model_name='wechatuser',
name='title',
field=models.CharField(blank=True, default='技术专家', max_length=50, verbose_name='专家头衔'),
),
]

View File

@@ -20,6 +20,11 @@ class WeChatUser(models.Model):
country = models.CharField(max_length=64, verbose_name="国家", blank=True)
province = models.CharField(max_length=64, verbose_name="省份", blank=True)
city = models.CharField(max_length=64, verbose_name="城市", blank=True)
# 明星技术用户/专家标识
is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户")
title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")

View File

@@ -22,8 +22,8 @@ class CommissionLogSerializer(serializers.ModelSerializer):
class WeChatUserSerializer(serializers.ModelSerializer):
class Meta:
model = WeChatUser
fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number']
read_only_fields = ['id', 'phone_number']
fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title']
read_only_fields = ['id', 'phone_number', 'is_star', 'title']
class DistributorSerializer(serializers.ModelSerializer):
user_info = WeChatUserSerializer(source='user', read_only=True)

View File

@@ -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 />} />

View File

@@ -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;

View 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;

View File

@@ -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 />,

View 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;

View 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;