From 2ef1771be0bc9f2dcc32bc66fb9425d6722b83c4 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Mon, 2 Mar 2026 20:28:16 +0800 Subject: [PATCH] heart --- .../0016_reply_likes_topic_likes.py | 24 +++++++++ backend/community/models.py | 2 + backend/community/serializers.py | 27 +++++++++- backend/community/views.py | 32 +++++++++++ frontend/src/api.js | 2 + frontend/src/pages/ForumDetail.jsx | 54 +++++++++++++++++-- frontend/src/pages/ForumList.jsx | 6 ++- miniprogram/src/api/index.ts | 2 + miniprogram/src/pages/forum/index.tsx | 4 ++ .../src/subpackages/forum/detail/index.tsx | 52 +++++++++++++++++- 10 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 backend/community/migrations/0016_reply_likes_topic_likes.py 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 = () => { {topic.view_count} 阅读 + + {topic.is_liked ? : } + {topic.like_count || 0} 点赞 + @@ -318,6 +360,12 @@ const ForumDetail = () => { {reply.author_info?.nickname} {reply.is_pinned && 置顶} {new Date(reply.created_at).toLocaleString()} + + + handleLikeReply(reply.id)} style={{ cursor: 'pointer' }}> + {reply.is_liked ? : } + {reply.like_count || 0} + + #{index + 1} - #{index + 1}
{
{item.view_count || 0}
+
+ +
{item.like_count || 0}
+
diff --git a/miniprogram/src/api/index.ts b/miniprogram/src/api/index.ts index 7078db5..38f3cd6 100644 --- a/miniprogram/src/api/index.ts +++ b/miniprogram/src/api/index.ts @@ -50,9 +50,11 @@ export const wechatLogin = (code: string) => request({ url: '/wechat/login/', me // Forum / Community export const getTopics = (params: any) => request({ url: '/community/topics/', data: params }) export const getTopicDetail = (id: number) => request({ url: `/community/topics/${id}/` }) +export const likeTopic = (id: number) => request({ url: `/community/topics/${id}/like/`, method: 'POST' }) export const createTopic = (data: any) => request({ url: '/community/topics/', method: 'POST', data }) export const updateTopic = (id: number, data: any) => request({ url: `/community/topics/${id}/`, method: 'PATCH', data }) export const getReplies = (params: any) => request({ url: '/community/replies/', data: params }) +export const likeReply = (id: number) => request({ url: `/community/replies/${id}/like/`, method: 'POST' }) export const createReply = (data: any) => request({ url: '/community/replies/', method: 'POST', data }) export const updateReply = (id: number, data: any) => request({ url: `/community/replies/${id}/`, method: 'PATCH', data }) export const deleteReply = (id: number) => request({ url: `/community/replies/${id}/`, method: 'DELETE' }) diff --git a/miniprogram/src/pages/forum/index.tsx b/miniprogram/src/pages/forum/index.tsx index 384da72..9db8c6b 100644 --- a/miniprogram/src/pages/forum/index.tsx +++ b/miniprogram/src/pages/forum/index.tsx @@ -310,6 +310,10 @@ const ForumList = () => { {item.view_count || 0} + + + {item.like_count || 0} + {item.replies?.length || 0} diff --git a/miniprogram/src/subpackages/forum/detail/index.tsx b/miniprogram/src/subpackages/forum/detail/index.tsx index dcb4f34..9708198 100644 --- a/miniprogram/src/subpackages/forum/detail/index.tsx +++ b/miniprogram/src/subpackages/forum/detail/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro' import { View, Text, Image, Video, Input, ScrollView } from '@tarojs/components' import { AtActivityIndicator, AtIcon, AtActionSheet, AtActionSheetItem, AtFloatLayout } from 'taro-ui' -import { getTopicDetail, createReply, uploadMedia, getStarUsers } from '../../../api' +import { getTopicDetail, createReply, uploadMedia, getStarUsers, likeTopic, likeReply } from '../../../api' import MarkdownReader from '../../../components/MarkdownReader' import './detail.scss' @@ -70,6 +70,48 @@ const ForumDetail = () => { setShowStarUsers(false) } + const handleLikeTopic = async () => { + const token = Taro.getStorageSync('token') + if (!token) { + Taro.showToast({ title: '请先登录', icon: 'none' }) + return + } + try { + const res = await likeTopic(topic.id) + const data = res.data || res + setTopic(prev => ({ + ...prev, + is_liked: data.liked, + like_count: data.count + })) + } catch (error) { + Taro.showToast({ title: '操作失败', icon: 'none' }) + } + } + + const handleLikeReply = async (replyId) => { + const token = Taro.getStorageSync('token') + if (!token) { + Taro.showToast({ title: '请先登录', icon: 'none' }) + return + } + try { + const res = await likeReply(replyId) + const data = res.data || res + setTopic(prev => ({ + ...prev, + replies: prev.replies.map(r => { + if (r.id === replyId) { + return { ...r, is_liked: data.liked, like_count: data.count } + } + return r + }) + })) + } catch (error) { + Taro.showToast({ title: '操作失败', icon: 'none' }) + } + } + // 分享给好友 useShareAppMessage(() => { return { @@ -197,6 +239,10 @@ const ForumDetail = () => { {topic.view_count} + + + {topic.like_count || 0} + {userInfo && topic.author === userInfo.id && ( @@ -238,6 +284,10 @@ const ForumDetail = () => { #{idx + 1} • {new Date(reply.created_at).toLocaleDateString()} + handleLikeReply(reply.id)} style={{marginRight: 10, display: 'flex', alignItems: 'center'}}> + + {reply.like_count || 0} + handleReplyToUser(reply.author_info?.nickname)} style={{marginRight: 10, padding: '2px 6px', background: '#f0f0f0', borderRadius: 4}}> 回复