new
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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.
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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="内容"
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
{getCategoryLabel(item.category)}
|
||||||
|
</Tag>
|
||||||
|
{item.is_verified_owner && (
|
||||||
|
<Tooltip title="已验证购买过相关产品">
|
||||||
|
<Tag icon={<CheckCircleFilled />} color="#00b96b" style={{ margin: 0 }}>认证用户</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: '#fff', fontSize: 18, fontWeight: 'bold', cursor: 'pointer' }}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Paragraph
|
<Paragraph
|
||||||
ellipsis={{ rows: 2 }}
|
ellipsis={{ rows: 2 }}
|
||||||
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
||||||
>
|
>
|
||||||
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
||||||
</Paragraph>
|
</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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user