heart
All checks were successful
Deploy to Server / deploy (push) Successful in 38s

This commit is contained in:
jeremygan2021
2026-03-02 20:28:16 +08:00
parent e306ac6f61
commit 2ef1771be0
10 changed files with 198 additions and 7 deletions

View File

@@ -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='点赞用户'),
),
]

View File

@@ -143,6 +143,7 @@ class Topic(models.Model):
related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程") related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程")
view_count = models.IntegerField(default=0, 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="置顶") is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
updated_at = models.DateTimeField(auto_now=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格式") content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式")
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者") 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="回复楼层") 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="置顶") is_pinned = models.BooleanField(default=False, verbose_name="置顶")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间") created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间")

View File

@@ -88,12 +88,23 @@ class ReplySerializer(serializers.ModelSerializer):
write_only=True, write_only=True,
required=False required=False
) )
like_count = serializers.IntegerField(source='likes.count', read_only=True)
is_liked = serializers.SerializerMethodField()
class Meta: class Meta:
model = Reply 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'] 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): def create(self, validated_data):
media_ids = validated_data.pop('media_ids', []) media_ids = validated_data.pop('media_ids', [])
reply = super().create(validated_data) reply = super().create(validated_data)
@@ -106,6 +117,8 @@ class TopicSerializer(serializers.ModelSerializer):
replies = ReplySerializer(many=True, read_only=True) replies = ReplySerializer(many=True, read_only=True)
media = TopicMediaSerializer(many=True, read_only=True) media = TopicMediaSerializer(many=True, read_only=True)
is_verified_owner = serializers.BooleanField(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) product_info = ESP32ConfigSerializer(source='related_product', read_only=True)
service_info = ServiceSerializer(source='related_service', 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_service', 'service_info',
'related_course', 'course_info', 'related_course', 'course_info',
'view_count', 'is_pinned', 'created_at', 'updated_at', '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'] 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): def create(self, validated_data):
media_ids = validated_data.pop('media_ids', []) media_ids = validated_data.pop('media_ids', [])
topic = super().create(validated_data) topic = super().create(validated_data)

View File

@@ -284,6 +284,22 @@ class TopicViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401) return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs) 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): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
instance.view_count += 1 instance.view_count += 1
@@ -310,6 +326,22 @@ class ReplyViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401) return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs) 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 import requests
class TopicMediaViewSet(viewsets.ViewSet): class TopicMediaViewSet(viewsets.ViewSet):

View File

@@ -51,9 +51,11 @@ export const uploadUserAvatar = (data) => {
// Community / Forum API // Community / Forum API
export const getTopics = (params) => api.get('/community/topics/', { params }); export const getTopics = (params) => api.get('/community/topics/', { params });
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`); 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 createTopic = (data) => api.post('/community/topics/', data);
export const updateTopic = (id, data) => api.patch(`/community/topics/${id}/`, data); export const updateTopic = (id, data) => api.patch(`/community/topics/${id}/`, data);
export const getReplies = (params) => api.get('/community/replies/', { params }); 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 createReply = (data) => api.post('/community/replies/', data);
export const uploadMedia = (data) => { export const uploadMedia = (data) => {
return api.post('/community/media/', data, { return api.post('/community/media/', data, {

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip, Grid, Modal } from 'antd'; 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 { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined, StarFilled, CloseOutlined, LikeOutlined, LikeFilled } from '@ant-design/icons';
import { getTopicDetail, createReply, uploadMedia, getStarUsers } from '../api'; import { getTopicDetail, createReply, uploadMedia, getStarUsers, likeTopic, likeReply } from '../api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import LoginModal from '../components/LoginModal'; import LoginModal from '../components/LoginModal';
import CreateTopicModal from '../components/CreateTopicModal'; 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 () => { const handleSubmitReply = async () => {
if (!user) { if (!user) {
setLoginModalVisible(true); setLoginModalVisible(true);
@@ -255,6 +293,10 @@ const ForumDetail = () => {
<EyeOutlined /> <EyeOutlined />
<span style={{ fontSize: isMobile ? 12 : 14 }}>{topic.view_count} 阅读</span> <span style={{ fontSize: isMobile ? 12 : 14 }}>{topic.view_count} 阅读</span>
</Space> </Space>
<Space style={{ cursor: 'pointer' }} onClick={handleLikeTopic}>
{topic.is_liked ? <LikeFilled style={{ color: '#00b96b' }} /> : <LikeOutlined />}
<span style={{ fontSize: isMobile ? 12 : 14, color: topic.is_liked ? '#00b96b' : 'inherit' }}>{topic.like_count || 0} 点赞</span>
</Space>
</Space> </Space>
</div> </div>
@@ -318,6 +360,12 @@ const ForumDetail = () => {
<Text style={{ color: '#aaa', fontWeight: 'bold', fontSize: isMobile ? 13 : 14 }}>{reply.author_info?.nickname}</Text> <Text style={{ color: '#aaa', fontWeight: 'bold', fontSize: isMobile ? 13 : 14 }}>{reply.author_info?.nickname}</Text>
{reply.is_pinned && <Tag color="red" style={{ margin: 0 }}>置顶</Tag>} {reply.is_pinned && <Tag color="red" style={{ margin: 0 }}>置顶</Tag>}
<Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text> <Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text>
</Space>
<Space size={isMobile ? 'small' : 'middle'} align="center">
<Space onClick={() => handleLikeReply(reply.id)} style={{ cursor: 'pointer' }}>
{reply.is_liked ? <LikeFilled style={{ color: '#00b96b' }} /> : <LikeOutlined style={{ color: '#666' }} />}
<span style={{ fontSize: 12, color: reply.is_liked ? '#00b96b' : '#666' }}>{reply.like_count || 0}</span>
</Space>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -326,8 +374,8 @@ const ForumDetail = () => {
> >
回复 回复
</Button> </Button>
<Text style={{ color: '#444', fontSize: 12 }}>#{index + 1}</Text>
</Space> </Space>
<Text style={{ color: '#444', fontSize: 12 }}>#{index + 1}</Text>
</div> </div>
<div style={{ color: '#eee', fontSize: isMobile ? 14 : 16 }} className={styles['markdown-body']}> <div style={{ color: '#eee', fontSize: isMobile ? 14 : 16 }} className={styles['markdown-body']}>
<ReactMarkdown <ReactMarkdown

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, Tabs, Row, Col, Grid, Carousel, Modal } from 'antd'; import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip, Tabs, Row, Col, Grid, Carousel, Modal } from 'antd';
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined, RightOutlined, CloseOutlined } from '@ant-design/icons'; import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined, RightOutlined, CloseOutlined, LikeOutlined } 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, getStarUsers, getAnnouncements } from '../api'; import { getTopics, getStarUsers, getAnnouncements } from '../api';
@@ -296,6 +296,10 @@ const ForumList = () => {
<EyeOutlined style={{ fontSize: isMobile ? 14 : 16, color: '#666' }} /> <EyeOutlined style={{ fontSize: isMobile ? 14 : 16, color: '#666' }} />
<div style={{ color: '#888', fontSize: isMobile ? 10 : 12 }}>{item.view_count || 0}</div> <div style={{ color: '#888', fontSize: isMobile ? 10 : 12 }}>{item.view_count || 0}</div>
</div> </div>
<div style={{ textAlign: 'center', marginTop: isMobile ? 2 : 5 }}>
<LikeOutlined style={{ fontSize: isMobile ? 14 : 16, color: '#666' }} />
<div style={{ color: '#888', fontSize: isMobile ? 10 : 12 }}>{item.like_count || 0}</div>
</div>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -50,9 +50,11 @@ export const wechatLogin = (code: string) => request({ url: '/wechat/login/', me
// Forum / Community // Forum / Community
export const getTopics = (params: any) => request({ url: '/community/topics/', data: params }) export const getTopics = (params: any) => request({ url: '/community/topics/', data: params })
export const getTopicDetail = (id: number) => request({ url: `/community/topics/${id}/` }) 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 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 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 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 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 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' }) export const deleteReply = (id: number) => request({ url: `/community/replies/${id}/`, method: 'DELETE' })

View File

@@ -310,6 +310,10 @@ const ForumList = () => {
<AtIcon value='eye' size='14' color='#777' /> <AtIcon value='eye' size='14' color='#777' />
<Text>{item.view_count || 0}</Text> <Text>{item.view_count || 0}</Text>
</View> </View>
<View className='stat-item'>
<AtIcon value='heart' size='14' color='#777' />
<Text>{item.like_count || 0}</Text>
</View>
<View className='stat-item'> <View className='stat-item'>
<AtIcon value='message' size='14' color='#777' /> <AtIcon value='message' size='14' color='#777' />
<Text>{item.replies?.length || 0}</Text> <Text>{item.replies?.length || 0}</Text>

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro' import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro'
import { View, Text, Image, Video, Input, ScrollView } from '@tarojs/components' import { View, Text, Image, Video, Input, ScrollView } from '@tarojs/components'
import { AtActivityIndicator, AtIcon, AtActionSheet, AtActionSheetItem, AtFloatLayout } from 'taro-ui' 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 MarkdownReader from '../../../components/MarkdownReader'
import './detail.scss' import './detail.scss'
@@ -70,6 +70,48 @@ const ForumDetail = () => {
setShowStarUsers(false) 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(() => { useShareAppMessage(() => {
return { return {
@@ -197,6 +239,10 @@ const ForumDetail = () => {
<AtIcon value='eye' size='14' color='#666' style={{marginRight: 4}} /> <AtIcon value='eye' size='14' color='#666' style={{marginRight: 4}} />
<Text>{topic.view_count}</Text> <Text>{topic.view_count}</Text>
</View> </View>
<View style={{display: 'flex', alignItems: 'center', marginLeft: 10}} onClick={handleLikeTopic}>
<AtIcon value={topic.is_liked ? 'heart-2' : 'heart'} size='14' color={topic.is_liked ? '#ff4d4f' : '#666'} style={{marginRight: 4}} />
<Text style={{color: topic.is_liked ? '#ff4d4f' : '#666'}}>{topic.like_count || 0}</Text>
</View>
{userInfo && topic.author === userInfo.id && ( {userInfo && topic.author === userInfo.id && (
<View onClick={handleEdit} style={{display: 'flex', alignItems: 'center', marginLeft: 'auto', padding: '6px 12px', background: 'rgba(0, 185, 107, 0.15)', borderRadius: 20}}> <View onClick={handleEdit} style={{display: 'flex', alignItems: 'center', marginLeft: 'auto', padding: '6px 12px', background: 'rgba(0, 185, 107, 0.15)', borderRadius: 20}}>
@@ -238,6 +284,10 @@ const ForumDetail = () => {
<Text style={{fontSize: 10, color: '#666', marginTop: 2}}>#{idx + 1} {new Date(reply.created_at).toLocaleDateString()}</Text> <Text style={{fontSize: 10, color: '#666', marginTop: 2}}>#{idx + 1} {new Date(reply.created_at).toLocaleDateString()}</Text>
</View> </View>
<View style={{display: 'flex', alignItems: 'center'}}> <View style={{display: 'flex', alignItems: 'center'}}>
<View onClick={() => handleLikeReply(reply.id)} style={{marginRight: 10, display: 'flex', alignItems: 'center'}}>
<AtIcon value={reply.is_liked ? 'heart-2' : 'heart'} size='14' color={reply.is_liked ? '#ff4d4f' : '#ccc'} />
<Text style={{fontSize: 10, color: reply.is_liked ? '#ff4d4f' : '#999', marginLeft: 2}}>{reply.like_count || 0}</Text>
</View>
<View onClick={() => handleReplyToUser(reply.author_info?.nickname)} style={{marginRight: 10, padding: '2px 6px', background: '#f0f0f0', borderRadius: 4}}> <View onClick={() => handleReplyToUser(reply.author_info?.nickname)} style={{marginRight: 10, padding: '2px 6px', background: '#f0f0f0', borderRadius: 4}}>
<Text style={{fontSize: 10, color: '#666'}}></Text> <Text style={{fontSize: 10, color: '#666'}}></Text>
</View> </View>