This commit is contained in:
jeremygan2021
2026-02-12 14:23:36 +08:00
parent f00cc9a28e
commit b4ac97c3c2
7 changed files with 201 additions and 82 deletions

View File

@@ -41,7 +41,7 @@ class TopicSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Topic model = Topic
fields = [ fields = [
'id', 'title', 'content', 'author', 'author_info', 'id', 'title', 'category', 'content', 'author', 'author_info',
'related_product', 'product_info', 'related_product', 'product_info',
'related_service', 'service_info', 'related_service', 'service_info',
'related_course', 'course_info', 'related_course', 'course_info',

View File

@@ -1,4 +1,5 @@
from rest_framework import viewsets, status, mixins, parsers from rest_framework import viewsets, status, mixins, parsers, filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import serializers from rest_framework import serializers
@@ -68,6 +69,11 @@ class TopicViewSet(viewsets.ModelViewSet):
""" """
queryset = Topic.objects.all() queryset = Topic.objects.all()
serializer_class = TopicSerializer serializer_class = TopicSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title', 'content']
filterset_fields = ['category', 'is_pinned']
ordering_fields = ['created_at', 'view_count']
ordering = ['-is_pinned', '-created_at']
def perform_create(self, serializer): def perform_create(self, serializer):
user = get_current_wechat_user(self.request) user = get_current_wechat_user(self.request)

Binary file not shown.

View File

@@ -7,9 +7,14 @@ django.setup()
from shop.models import ESP32Config from shop.models import ESP32Config
def populate(): def populate():
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
if ESP32Config.objects.exists():
print("ESP32Config data already exists, skipping population.")
return
# 清除旧数据,避免重复累积 # 清除旧数据,避免重复累积
# 注意:在生产环境中慎用 delete # 注意:在生产环境中慎用 delete
ESP32Config.objects.all().delete() # ESP32Config.objects.all().delete()
configs = [ configs = [
{ {

View File

@@ -60,5 +60,9 @@ export const uploadMedia = (data) => {
} }
}); });
}; };
// 获取明星技术用户 (目前暂无专门接口,通过 /wechat/login 返回的 token 获取当前用户信息,或者通过 filter 获取用户列表如果后端开放)
// 由于没有专门的用户列表接口,我们暂时不实现 getStarUsers API或者在 ForumList 中模拟或请求特定的 Top 榜单接口。
// 为了演示,我们假设后端开放一个 user list 接口,或者我们修改 Topic 列表返回 author_info 时前端自行筛选。
// 最好的方式是后端提供一个 star_users 接口。我们暂时跳过,只在 ForumList 中处理。
export default api; export default api;

View File

@@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, Form, Input, Button, message, Upload } from 'antd'; import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
import { InboxOutlined } from '@ant-design/icons'; import { InboxOutlined } from '@ant-design/icons';
import { createTopic } from '../api'; import { createTopic } from '../api';
const { TextArea } = Input; const { TextArea } = Input;
const { Dragger } = Upload; const { Option } = Select;
const CreateTopicModal = ({ visible, onClose, onSuccess }) => { const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -39,6 +39,7 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
layout="vertical" layout="vertical"
onFinish={handleSubmit} onFinish={handleSubmit}
style={{ marginTop: 20 }} style={{ marginTop: 20 }}
initialValues={{ category: 'discussion' }}
> >
<Form.Item <Form.Item
name="title" name="title"
@@ -48,6 +49,18 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
<Input placeholder="请输入清晰的问题或讨论标题" /> <Input placeholder="请输入清晰的问题或讨论标题" />
</Form.Item> </Form.Item>
<Form.Item
name="category"
label="分类"
rules={[{ required: true, message: '请选择分类' }]}
>
<Select>
<Option value="discussion">技术讨论</Option>
<Option value="help">求助问答</Option>
<Option value="share">经验分享</Option>
</Select>
</Form.Item>
<Form.Item <Form.Item
name="content" name="content"
label="内容" label="内容"

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip } from 'antd'; 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 } from '@ant-design/icons'; import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { getTopics } from '../api'; import { getTopics } from '../api';
@@ -17,14 +17,17 @@ const ForumList = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [topics, setTopics] = useState([]); const [topics, setTopics] = useState([]);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [category, setCategory] = useState('all');
const [createModalVisible, setCreateModalVisible] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false);
const [loginModalVisible, setLoginModalVisible] = useState(false); const [loginModalVisible, setLoginModalVisible] = useState(false);
const fetchTopics = async (search = '') => { const fetchTopics = async (search = '', cat = '') => {
setLoading(true); setLoading(true);
try { try {
const params = {}; const params = {};
if (search) params.search = search; if (search) params.search = search;
if (cat && cat !== 'all') params.category = cat;
const res = await getTopics(params); const res = await getTopics(params);
setTopics(res.data.results || res.data); // Support pagination result or list setTopics(res.data.results || res.data); // Support pagination result or list
} catch (error) { } catch (error) {
@@ -36,12 +39,12 @@ const ForumList = () => {
}; };
useEffect(() => { useEffect(() => {
fetchTopics(); fetchTopics(searchText, category);
}, []); }, [category]);
const handleSearch = (value) => { const handleSearch = (value) => {
setSearchText(value); setSearchText(value);
fetchTopics(value); fetchTopics(value, category);
}; };
const handleCreateClick = () => { const handleCreateClick = () => {
@@ -52,6 +55,32 @@ const ForumList = () => {
setCreateModalVisible(true); setCreateModalVisible(true);
}; };
const getCategoryIcon = (cat) => {
switch(cat) {
case 'help': return <QuestionCircleOutlined style={{ color: '#faad14' }} />;
case 'share': return <ShareAltOutlined style={{ color: '#1890ff' }} />;
case 'notice': return <SoundOutlined style={{ color: '#ff4d4f' }} />;
default: return <MessageOutlined style={{ color: '#00b96b' }} />;
}
};
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 ( return (
<div style={{ minHeight: '100vh', paddingBottom: 60 }}> <div style={{ minHeight: '100vh', paddingBottom: 60 }}>
{/* Hero Section */} {/* Hero Section */}
@@ -95,91 +124,153 @@ const ForumList = () => {
</div> </div>
{/* Content Section */} {/* Content Section */}
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '0 20px' }}> <div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 20px' }}>
<List <Row gutter={24}>
loading={loading} <Col xs={24} md={18}>
itemLayout="vertical" <Tabs
dataSource={topics} defaultActiveKey="all"
renderItem={(item) => ( items={items}
<motion.div onChange={setCategory}
initial={{ opacity: 0, y: 10 }} tabBarStyle={{ color: '#fff' }}
whileInView={{ opacity: 1, y: 0 }} />
viewport={{ once: true }} <List
> loading={loading}
<Card itemLayout="vertical"
hoverable dataSource={topics}
style={{ renderItem={(item) => (
marginBottom: 16, <motion.div
background: 'rgba(20,20,20,0.6)', initial={{ opacity: 0, y: 10 }}
border: '1px solid rgba(255,255,255,0.1)', whileInView={{ opacity: 1, y: 0 }}
backdropFilter: 'blur(10px)' viewport={{ once: true }}
}} >
bodyStyle={{ padding: '20px 24px' }} <Card
onClick={() => navigate(`/forum/${item.id}`)} hoverable
> style={{
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> marginBottom: 16,
<div style={{ flex: 1 }}> background: 'rgba(20,20,20,0.6)',
<div style={{ marginBottom: 8 }}> border: item.is_pinned ? '1px solid rgba(0, 185, 107, 0.4)' : '1px solid rgba(255,255,255,0.1)',
{item.is_pinned && <Tag color="red">置顶</Tag>} backdropFilter: 'blur(10px)',
{item.is_verified_owner && ( boxShadow: item.is_pinned ? '0 0 10px rgba(0, 185, 107, 0.1)' : 'none'
<Tooltip title="已验证购买过相关产品"> }}
<Tag icon={<CheckCircleFilled />} color="#00b96b">认证用户</Tag> bodyStyle={{ padding: '20px 24px' }}
</Tooltip> onClick={() => navigate(`/forum/${item.id}`)}
)} >
<Text style={{ color: '#fff', fontSize: 18, fontWeight: 'bold', cursor: 'pointer' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
{item.title} <div style={{ flex: 1 }}>
</Text> <div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
</div> {item.is_pinned && <Tag color="red" icon={<FireOutlined />}>置顶</Tag>}
<Tag icon={getCategoryIcon(item.category)} style={{ background: 'transparent', color: '#fff', border: '1px solid #444' }}>
<Paragraph {getCategoryLabel(item.category)}
ellipsis={{ rows: 2 }} </Tag>
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }} {item.is_verified_owner && (
> <Tooltip title="已验证购买过相关产品">
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */} <Tag icon={<CheckCircleFilled />} color="#00b96b" style={{ margin: 0 }}>认证用户</Tag>
</Paragraph> </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 size="middle" style={{ color: '#666', fontSize: 13 }}>
<Space> <Space>
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" /> <Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
<Text style={{ color: '#888' }}>{item.author_info?.nickname || '匿名用户'}</Text> <Text style={{ color: item.author_info?.is_star ? '#ffd700' : '#888', fontWeight: item.author_info?.is_star ? 'bold' : 'normal' }}>
</Space> {item.author_info?.nickname || '匿名用户'}
<span></span> </Text>
<span>{new Date(item.created_at).toLocaleDateString()}</span> {item.author_info?.is_star && (
{item.product_info && ( <Tooltip title={item.author_info.title || "技术专家"}>
<Tag color="blue" style={{ marginLeft: 8 }}>{item.product_info.name}</Tag> <StarFilled style={{ color: '#ffd700' }} />
)} </Tooltip>
</Space> )}
</div> </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={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, minWidth: 80 }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<MessageOutlined style={{ fontSize: 16, color: '#00b96b' }} /> <MessageOutlined style={{ fontSize: 16, color: '#00b96b' }} />
<div style={{ color: '#fff', fontWeight: 'bold' }}>{item.replies?.length || 0}</div> <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> }}
/>
</Col>
<Col xs={0} md={6}>
<Card
title={<Space><StarFilled style={{ color: '#ffd700' }} /><span style={{ color: '#fff' }}>技术专家榜</span></Space>}
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)' }}
>
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
{/* 这里可以通过 API 获取专家列表,目前先做静态展示或从帖子中提取 */}
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert1" />
<div style={{ textAlign: 'left' }}>
<div style={{ color: '#fff', fontWeight: 'bold' }}>QuantMaster <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
<div style={{ color: '#666', fontSize: 12 }}>官方技术支持</div>
</div>
</div>
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert2" />
<div style={{ textAlign: 'left' }}>
<div style={{ color: '#fff', fontWeight: 'bold' }}>AI_Wizard <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
<div style={{ color: '#666', fontSize: 12 }}>社区贡献者</div>
</div>
</div>
</div> </div>
<div style={{ textAlign: 'center', marginTop: 5 }}> </Card>
<EyeOutlined style={{ fontSize: 16, color: '#666' }} />
<div style={{ color: '#888', fontSize: 12 }}>{item.view_count || 0}</div> <Card
</div> title={<Space><SoundOutlined style={{ color: '#ff4d4f' }} /><span style={{ color: '#fff' }}>社区公告</span></Space>}
</div> style={{ marginTop: 20, background: 'rgba(20,20,20,0.6)', border: '1px solid rgba(255,255,255,0.1)', backdropFilter: 'blur(10px)' }}
</div> headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
</Card> >
</motion.div> <List
)} size="small"
locale={{ emptyText: <div style={{ color: '#666', padding: 40 }}>暂无帖子来发布第一个吧</div> }} dataSource={[
/> '欢迎来到 Quant Speed 开发者社区',
'发帖前请阅读社区规范',
'如何获取“认证用户”标识?'
]}
renderItem={item => <List.Item style={{ color: '#aaa', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>{item}</List.Item>}
/>
</Card>
</Col>
</Row>
</div> </div>
<CreateTopicModal <CreateTopicModal
visible={createModalVisible} visible={createModalVisible}
onClose={() => setCreateModalVisible(false)} onClose={() => setCreateModalVisible(false)}
onSuccess={() => fetchTopics(searchText)} onSuccess={() => fetchTopics(searchText, category)}
/> />
<LoginModal <LoginModal
visible={loginModalVisible} visible={loginModalVisible}
onClose={() => setLoginModalVisible(false)} onClose={() => setLoginModalVisible(false)}
onLoginSuccess={() => { onLoginSuccess={() => {
// 登录成功后自动打开发布框
setCreateModalVisible(true); setCreateModalVisible(true);
}} }}
/> />