feat: 支持用户删除自己的评论

- CommentItem: 添加删除按钮(仅显示在自己的评论上)
- CommentItem: 添加删除确认对话框,防止误删
- CommentList: 传递 currentUserId 和 onDelete 到 CommentItem
- EventCommentSection: 添加 handleDeleteComment 处理函数
- mock handler: 使用真实登录用户信息创建评论

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-15 13:56:02 +08:00
parent e493ae5ad1
commit a89489ba46
4 changed files with 197 additions and 46 deletions

View File

@@ -1,29 +1,63 @@
// src/components/EventCommentSection/CommentItem.js // src/components/EventCommentSection/CommentItem.js
/** /**
* 单条评论组件 * 单条评论组件
* 功能:显示用户头像、昵称、时间、评论内容 * 功能:显示用户头像、昵称、时间、评论内容、删除按钮
*/ */
import React from 'react'; import React, { useState } from 'react';
import { import {
Box, Box,
HStack, HStack,
VStack, VStack,
Avatar, Avatar,
Text, Text,
IconButton,
useColorModeValue, useColorModeValue,
Tooltip,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Button,
useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { DeleteIcon } from '@chakra-ui/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const CommentItem = ({ comment }) => { const CommentItem = ({ comment, currentUserId, onDelete }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = React.useRef();
const [isDeleting, setIsDeleting] = useState(false);
const itemBg = useColorModeValue('gray.50', 'gray.700'); const itemBg = useColorModeValue('gray.50', 'gray.700');
const usernameColor = useColorModeValue('gray.800', 'gray.100'); const usernameColor = useColorModeValue('gray.800', 'gray.100');
const timeColor = useColorModeValue('gray.500', 'gray.400'); const timeColor = useColorModeValue('gray.500', 'gray.400');
const contentColor = useColorModeValue('gray.700', 'gray.300'); const contentColor = useColorModeValue('gray.700', 'gray.300');
// 判断当前用户是否可以删除此评论
const canDelete = currentUserId && (
comment.author?.id === currentUserId ||
comment.user_id === currentUserId
);
// 处理删除
const handleDelete = async () => {
if (!onDelete) return;
setIsDeleting(true);
try {
await onDelete(comment.id);
onClose();
} catch (error) {
// 错误由父组件处理
} finally {
setIsDeleting(false);
}
};
// 格式化时间 // 格式化时间
const formatTime = (timestamp) => { const formatTime = (timestamp) => {
const now = dayjs(); const now = dayjs();
@@ -46,6 +80,7 @@ const CommentItem = ({ comment }) => {
}; };
return ( return (
<>
<Box <Box
p={3} p={3}
bg={itemBg} bg={itemBg}
@@ -55,6 +90,7 @@ const CommentItem = ({ comment }) => {
transform: 'translateY(-2px)', transform: 'translateY(-2px)',
boxShadow: 'sm', boxShadow: 'sm',
}} }}
position="relative"
> >
<HStack align="start" spacing={3}> <HStack align="start" spacing={3}>
{/* 用户头像 */} {/* 用户头像 */}
@@ -67,6 +103,7 @@ const CommentItem = ({ comment }) => {
{/* 评论内容区 */} {/* 评论内容区 */}
<VStack align="stretch" flex={1} spacing={1}> <VStack align="stretch" flex={1} spacing={1}>
{/* 用户名和时间 */} {/* 用户名和时间 */}
<HStack spacing={2} justify="space-between">
<HStack spacing={2}> <HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold" color={usernameColor}> <Text fontSize="sm" fontWeight="bold" color={usernameColor}>
{comment.author?.username || 'Anonymous'} {comment.author?.username || 'Anonymous'}
@@ -76,6 +113,23 @@ const CommentItem = ({ comment }) => {
</Text> </Text>
</HStack> </HStack>
{/* 删除按钮 - 只对自己的评论显示 */}
{canDelete && (
<Tooltip label="删除评论" placement="top">
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="ghost"
colorScheme="red"
aria-label="删除评论"
onClick={onOpen}
opacity={0.6}
_hover={{ opacity: 1 }}
/>
</Tooltip>
)}
</HStack>
{/* 评论内容 */} {/* 评论内容 */}
<Text fontSize="sm" color={contentColor} lineHeight="1.6"> <Text fontSize="sm" color={contentColor} lineHeight="1.6">
{comment.content} {comment.content}
@@ -83,6 +137,42 @@ const CommentItem = ({ comment }) => {
</VStack> </VStack>
</HStack> </HStack>
</Box> </Box>
{/* 删除确认对话框 */}
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
删除评论
</AlertDialogHeader>
<AlertDialogBody>
确定要删除这条评论吗此操作不可撤销
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} isDisabled={isDeleting}>
取消
</Button>
<Button
colorScheme="red"
onClick={handleDelete}
ml={3}
isLoading={isDeleting}
loadingText="删除中..."
>
删除
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
); );
}; };

View File

@@ -16,7 +16,7 @@ import {
import { ChatIcon } from '@chakra-ui/icons'; import { ChatIcon } from '@chakra-ui/icons';
import CommentItem from './CommentItem'; import CommentItem from './CommentItem';
const CommentList = ({ comments, loading }) => { const CommentList = ({ comments, loading, currentUserId, onDelete }) => {
const emptyTextColor = useColorModeValue('gray.500', 'gray.400'); const emptyTextColor = useColorModeValue('gray.500', 'gray.400');
const emptyBgColor = useColorModeValue('gray.50', 'gray.700'); const emptyBgColor = useColorModeValue('gray.50', 'gray.700');
@@ -58,7 +58,12 @@ const CommentList = ({ comments, loading }) => {
return ( return (
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
{comments.map((comment) => ( {comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} /> <CommentItem
key={comment.id}
comment={comment}
currentUserId={currentUserId}
onDelete={onDelete}
/>
))} ))}
</VStack> </VStack>
); );

View File

@@ -144,8 +144,9 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
content_type: 'text', content_type: 'text',
author: { author: {
id: user?.id || 'current_user', id: user?.id || 'current_user',
username: user?.username || '当前用户', // 与导航区保持一致:优先显示昵称
avatar: user?.avatar || null, username: user?.nickname || user?.username || user?.email || '当前用户',
avatar: user?.avatar_url || null,
}, },
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
likes_count: 0, likes_count: 0,
@@ -187,6 +188,51 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
} }
}, [eventId, commentText, toast, user, setComments, setTotalCount]); }, [eventId, commentText, toast, user, setComments, setTotalCount]);
/**
* 删除评论
*/
const handleDeleteComment = useCallback(async (commentId: string | number) => {
try {
const result = await eventService.deletePost(commentId);
if (result.success) {
// 从本地 state 中移除该评论
setComments((prevComments) =>
prevComments.filter((comment) => comment.id !== commentId)
);
// 总评论数 -1
setTotalCount((prevTotal) => Math.max(0, prevTotal - 1));
toast({
title: '评论已删除',
status: 'success',
duration: 2000,
isClosable: true,
});
logger.info('EventCommentSection', '评论删除成功', {
eventId,
commentId,
});
} else {
throw new Error(result.message || '删除评论失败');
}
} catch (error: any) {
logger.error('EventCommentSection', 'handleDeleteComment', error, {
eventId,
commentId,
});
toast({
title: '删除评论失败',
description: error.message || '请稍后重试',
status: 'error',
duration: 3000,
isClosable: true,
});
throw error; // 重新抛出让 CommentItem 知道删除失败
}
}, [eventId, toast, setComments, setTotalCount]);
return ( return (
<Box> <Box>
{/* 标题栏 */} {/* 标题栏 */}
@@ -203,7 +249,12 @@ const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) =>
{/* 评论列表 */} {/* 评论列表 */}
<Box mb={4}> <Box mb={4}>
<CommentList comments={comments} loading={loading} /> <CommentList
comments={comments}
loading={loading}
currentUserId={user?.id}
onDelete={handleDeleteComment}
/>
</Box> </Box>
{/* 加载更多按钮(仅当有更多评论时显示) */} {/* 加载更多按钮(仅当有更多评论时显示) */}

View File

@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events'; import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth, toggleEventFollowStatus, isEventFollowed } from '../data/account'; import { getMockFutureEvents, getMockEventCountsForMonth, toggleEventFollowStatus, isEventFollowed } from '../data/account';
import { generatePopularConcepts } from './concept'; import { generatePopularConcepts } from './concept';
import { getCurrentUser } from '../data/users';
// 模拟网络延迟 // 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms)); const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
@@ -1498,15 +1499,19 @@ export const eventHandlers = [
console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content); console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content);
try { try {
// 获取当前登录用户信息
const currentUser = getCurrentUser();
// 模拟创建新评论 // 模拟创建新评论
const newComment = { const newComment = {
id: `comment_${eventId}_${Date.now()}`, id: `comment_${eventId}_${Date.now()}`,
content: body.content, content: body.content,
content_type: body.content_type || 'text', content_type: body.content_type || 'text',
author: { author: {
id: 'current_user', id: currentUser?.id || 'current_user',
username: '当前用户', // 与导航区保持一致:优先显示昵称
avatar: null, username: currentUser?.nickname || currentUser?.username || currentUser?.email || '当前用户',
avatar: currentUser?.avatar_url || null,
}, },
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
likes_count: 0, likes_count: 0,