new
This commit is contained in:
@@ -41,7 +41,7 @@ class TopicSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Topic
|
||||
fields = [
|
||||
'id', 'title', 'content', 'author', 'author_info',
|
||||
'id', 'title', 'category', 'content', 'author', 'author_info',
|
||||
'related_product', 'product_info',
|
||||
'related_service', 'service_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.response import Response
|
||||
from rest_framework import serializers
|
||||
@@ -68,6 +69,11 @@ class TopicViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
queryset = Topic.objects.all()
|
||||
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):
|
||||
user = get_current_wechat_user(self.request)
|
||||
|
||||
Binary file not shown.
@@ -7,9 +7,14 @@ django.setup()
|
||||
from shop.models import ESP32Config
|
||||
|
||||
def populate():
|
||||
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
|
||||
if ESP32Config.objects.exists():
|
||||
print("ESP32Config data already exists, skipping population.")
|
||||
return
|
||||
|
||||
# 清除旧数据,避免重复累积
|
||||
# 注意:在生产环境中慎用 delete
|
||||
ESP32Config.objects.all().delete()
|
||||
# ESP32Config.objects.all().delete()
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { createTopic } from '../api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
const { Option } = Select;
|
||||
|
||||
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
@@ -39,6 +39,7 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
style={{ marginTop: 20 }}
|
||||
initialValues={{ category: 'discussion' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
@@ -48,6 +49,18 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
<Input placeholder="请输入清晰的问题或讨论标题" />
|
||||
</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
|
||||
name="content"
|
||||
label="内容"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { 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, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getTopics } from '../api';
|
||||
@@ -17,14 +17,17 @@ const ForumList = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [loginModalVisible, setLoginModalVisible] = useState(false);
|
||||
|
||||
const fetchTopics = async (search = '') => {
|
||||
const fetchTopics = async (search = '', cat = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (search) params.search = search;
|
||||
if (cat && cat !== 'all') params.category = cat;
|
||||
|
||||
const res = await getTopics(params);
|
||||
setTopics(res.data.results || res.data); // Support pagination result or list
|
||||
} catch (error) {
|
||||
@@ -36,12 +39,12 @@ const ForumList = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopics();
|
||||
}, []);
|
||||
fetchTopics(searchText, category);
|
||||
}, [category]);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
setSearchText(value);
|
||||
fetchTopics(value);
|
||||
fetchTopics(value, category);
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
@@ -52,6 +55,32 @@ const ForumList = () => {
|
||||
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 (
|
||||
<div style={{ minHeight: '100vh', paddingBottom: 60 }}>
|
||||
{/* Hero Section */}
|
||||
@@ -95,91 +124,153 @@ const ForumList = () => {
|
||||
</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>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 20px' }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={18}>
|
||||
<Tabs
|
||||
defaultActiveKey="all"
|
||||
items={items}
|
||||
onChange={setCategory}
|
||||
tabBarStyle={{ color: '#fff' }}
|
||||
/>
|
||||
<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: item.is_pinned ? '1px solid rgba(0, 185, 107, 0.4)' : '1px solid rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: item.is_pinned ? '0 0 10px rgba(0, 185, 107, 0.1)' : 'none'
|
||||
}}
|
||||
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, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{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
|
||||
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>
|
||||
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
|
||||
<Space>
|
||||
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
|
||||
<Text style={{ color: item.author_info?.is_star ? '#ffd700' : '#888', fontWeight: item.author_info?.is_star ? 'bold' : 'normal' }}>
|
||||
{item.author_info?.nickname || '匿名用户'}
|
||||
</Text>
|
||||
{item.author_info?.is_star && (
|
||||
<Tooltip title={item.author_info.title || "技术专家"}>
|
||||
<StarFilled style={{ color: '#ffd700' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</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 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> }}
|
||||
/>
|
||||
</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 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> }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<Space><SoundOutlined style={{ color: '#ff4d4f' }} /><span style={{ color: '#fff' }}>社区公告</span></Space>}
|
||||
style={{ marginTop: 20, 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)' }}
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
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>
|
||||
|
||||
<CreateTopicModal
|
||||
visible={createModalVisible}
|
||||
onClose={() => setCreateModalVisible(false)}
|
||||
onSuccess={() => fetchTopics(searchText)}
|
||||
onSuccess={() => fetchTopics(searchText, category)}
|
||||
/>
|
||||
|
||||
<LoginModal
|
||||
visible={loginModalVisible}
|
||||
onClose={() => setLoginModalVisible(false)}
|
||||
onLoginSuccess={() => {
|
||||
// 登录成功后自动打开发布框
|
||||
setCreateModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user