diff --git a/backend/community/migrations/0016_reply_likes_topic_likes.py b/backend/community/migrations/0016_reply_likes_topic_likes.py
new file mode 100644
index 0000000..ee850c4
--- /dev/null
+++ b/backend/community/migrations/0016_reply_likes_topic_likes.py
@@ -0,0 +1,24 @@
+# Generated by Django 6.0.1 on 2026-03-02 12:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('community', '0015_topic_status'),
+ ('shop', '0039_vccourse_video_embed_code'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='reply',
+ name='likes',
+ field=models.ManyToManyField(blank=True, related_name='liked_replies', to='shop.wechatuser', verbose_name='点赞用户'),
+ ),
+ migrations.AddField(
+ model_name='topic',
+ name='likes',
+ field=models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户'),
+ ),
+ ]
diff --git a/backend/community/models.py b/backend/community/models.py
index 9225fe3..6d30769 100644
--- a/backend/community/models.py
+++ b/backend/community/models.py
@@ -143,6 +143,7 @@ class Topic(models.Model):
related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程")
view_count = models.IntegerField(default=0, verbose_name="浏览量")
+ likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, verbose_name="点赞用户")
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
@@ -209,6 +210,7 @@ class Reply(models.Model):
content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式")
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者")
reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层")
+ likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户")
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间")
diff --git a/backend/community/serializers.py b/backend/community/serializers.py
index 4c95832..ed18821 100644
--- a/backend/community/serializers.py
+++ b/backend/community/serializers.py
@@ -88,12 +88,23 @@ class ReplySerializer(serializers.ModelSerializer):
write_only=True,
required=False
)
+ like_count = serializers.IntegerField(source='likes.count', read_only=True)
+ is_liked = serializers.SerializerMethodField()
class Meta:
model = Reply
- fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned']
+ fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked']
read_only_fields = ['author', 'created_at']
+ def get_is_liked(self, obj):
+ request = self.context.get('request')
+ if not request:
+ return False
+ user = get_current_wechat_user(request)
+ if user:
+ return obj.likes.filter(id=user.id).exists()
+ return False
+
def create(self, validated_data):
media_ids = validated_data.pop('media_ids', [])
reply = super().create(validated_data)
@@ -106,6 +117,8 @@ class TopicSerializer(serializers.ModelSerializer):
replies = ReplySerializer(many=True, read_only=True)
media = TopicMediaSerializer(many=True, read_only=True)
is_verified_owner = serializers.BooleanField(read_only=True)
+ like_count = serializers.IntegerField(source='likes.count', read_only=True)
+ is_liked = serializers.SerializerMethodField()
product_info = ESP32ConfigSerializer(source='related_product', read_only=True)
service_info = ServiceSerializer(source='related_service', read_only=True)
@@ -125,10 +138,20 @@ class TopicSerializer(serializers.ModelSerializer):
'related_service', 'service_info',
'related_course', 'course_info',
'view_count', 'is_pinned', 'created_at', 'updated_at',
- 'is_verified_owner', 'replies', 'media', 'media_ids'
+ 'is_verified_owner', 'replies', 'media', 'media_ids',
+ 'like_count', 'is_liked'
]
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status']
+ def get_is_liked(self, obj):
+ request = self.context.get('request')
+ if not request:
+ return False
+ user = get_current_wechat_user(request)
+ if user:
+ return obj.likes.filter(id=user.id).exists()
+ return False
+
def create(self, validated_data):
media_ids = validated_data.pop('media_ids', [])
topic = super().create(validated_data)
diff --git a/backend/community/views.py b/backend/community/views.py
index fa09654..2969df5 100644
--- a/backend/community/views.py
+++ b/backend/community/views.py
@@ -284,6 +284,22 @@ class TopicViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs)
+ @action(detail=True, methods=['post'])
+ def like(self, request, pk=None):
+ obj = self.get_object()
+ user = get_current_wechat_user(request)
+ if not user:
+ return Response({'error': '请先登录'}, status=401)
+
+ if obj.likes.filter(id=user.id).exists():
+ obj.likes.remove(user)
+ liked = False
+ else:
+ obj.likes.add(user)
+ liked = True
+
+ return Response({'liked': liked, 'count': obj.likes.count()})
+
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
instance.view_count += 1
@@ -310,6 +326,22 @@ class ReplyViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs)
+ @action(detail=True, methods=['post'])
+ def like(self, request, pk=None):
+ obj = self.get_object()
+ user = get_current_wechat_user(request)
+ if not user:
+ return Response({'error': '请先登录'}, status=401)
+
+ if obj.likes.filter(id=user.id).exists():
+ obj.likes.remove(user)
+ liked = False
+ else:
+ obj.likes.add(user)
+ liked = True
+
+ return Response({'liked': liked, 'count': obj.likes.count()})
+
import requests
class TopicMediaViewSet(viewsets.ViewSet):
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 202ba2a..68d26a2 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -51,9 +51,11 @@ export const uploadUserAvatar = (data) => {
// Community / Forum API
export const getTopics = (params) => api.get('/community/topics/', { params });
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
+export const likeTopic = (id) => api.post(`/community/topics/${id}/like/`);
export const createTopic = (data) => api.post('/community/topics/', data);
export const updateTopic = (id, data) => api.patch(`/community/topics/${id}/`, data);
export const getReplies = (params) => api.get('/community/replies/', { params });
+export const likeReply = (id) => api.post(`/community/replies/${id}/like/`);
export const createReply = (data) => api.post('/community/replies/', data);
export const uploadMedia = (data) => {
return api.post('/community/media/', data, {
diff --git a/frontend/src/pages/ForumDetail.jsx b/frontend/src/pages/ForumDetail.jsx
index 1df7cc1..3f5fea0 100644
--- a/frontend/src/pages/ForumDetail.jsx
+++ b/frontend/src/pages/ForumDetail.jsx
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip, Grid, Modal } from 'antd';
-import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined, StarFilled, CloseOutlined } from '@ant-design/icons';
-import { getTopicDetail, createReply, uploadMedia, getStarUsers } from '../api';
+import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined, StarFilled, CloseOutlined, LikeOutlined, LikeFilled } from '@ant-design/icons';
+import { getTopicDetail, createReply, uploadMedia, getStarUsers, likeTopic, likeReply } from '../api';
import { useAuth } from '../context/AuthContext';
import LoginModal from '../components/LoginModal';
import CreateTopicModal from '../components/CreateTopicModal';
@@ -94,6 +94,44 @@ const ForumDetail = () => {
}
};
+ const handleLikeTopic = async () => {
+ if (!user) {
+ setLoginModalVisible(true);
+ return;
+ }
+ try {
+ const res = await likeTopic(topic.id);
+ setTopic(prev => ({
+ ...prev,
+ is_liked: res.data.liked,
+ like_count: res.data.count
+ }));
+ } catch (error) {
+ message.error('操作失败');
+ }
+ };
+
+ const handleLikeReply = async (replyId) => {
+ if (!user) {
+ setLoginModalVisible(true);
+ return;
+ }
+ try {
+ const res = await likeReply(replyId);
+ setTopic(prev => ({
+ ...prev,
+ replies: prev.replies.map(r => {
+ if (r.id === replyId) {
+ return { ...r, is_liked: res.data.liked, like_count: res.data.count };
+ }
+ return r;
+ })
+ }));
+ } catch (error) {
+ message.error('操作失败');
+ }
+ };
+
const handleSubmitReply = async () => {
if (!user) {
setLoginModalVisible(true);
@@ -255,6 +293,10 @@ const ForumDetail = () => {