Merge branch 'feature_bugfix/251113_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251113_ui
This commit is contained in:
77
src/components/EventCommentSection/CommentInput.js
Normal file
77
src/components/EventCommentSection/CommentInput.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/components/EventCommentSection/CommentInput.js
|
||||
/**
|
||||
* 评论输入框组件
|
||||
* 功能:输入评论内容、字数限制、发布按钮
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Textarea,
|
||||
Button,
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
const CommentInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
maxLength = 500,
|
||||
placeholder = '说点什么...',
|
||||
}) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const countColor = useColorModeValue('gray.500', 'gray.500');
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Ctrl/Cmd + Enter 快捷键提交
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
bg={bgColor}
|
||||
borderColor={borderColor}
|
||||
color={textColor}
|
||||
rows={3}
|
||||
maxLength={maxLength}
|
||||
resize="vertical"
|
||||
_hover={{
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
_focus={{
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)',
|
||||
}}
|
||||
/>
|
||||
<HStack justify="space-between" mt={2}>
|
||||
<Text fontSize="sm" color={countColor}>
|
||||
{value.length}/{maxLength}
|
||||
{value.length === 0 && ' · Ctrl+Enter 快速发布'}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={onSubmit}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!value.trim() || isSubmitting}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentInput;
|
||||
89
src/components/EventCommentSection/CommentItem.js
Normal file
89
src/components/EventCommentSection/CommentItem.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// src/components/EventCommentSection/CommentItem.js
|
||||
/**
|
||||
* 单条评论组件
|
||||
* 功能:显示用户头像、昵称、时间、评论内容
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Avatar,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const usernameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const timeColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const contentColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '刚刚';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分钟前`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return time.format('MM-DD HH:mm');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={itemBg}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
>
|
||||
<HStack align="start" spacing={3}>
|
||||
{/* 用户头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={comment.author?.username || 'Anonymous'}
|
||||
src={comment.author?.avatar}
|
||||
/>
|
||||
|
||||
{/* 评论内容区 */}
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
{/* 用户名和时间 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={usernameColor}>
|
||||
{comment.author?.username || 'Anonymous'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={timeColor}>
|
||||
{formatTime(comment.created_at)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<Text fontSize="sm" color={contentColor} lineHeight="1.6">
|
||||
{comment.content}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentItem;
|
||||
67
src/components/EventCommentSection/CommentList.js
Normal file
67
src/components/EventCommentSection/CommentList.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/components/EventCommentSection/CommentList.js
|
||||
/**
|
||||
* 评论列表组件
|
||||
* 功能:展示评论列表、加载状态、空状态
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Spinner,
|
||||
Center,
|
||||
Text,
|
||||
Box,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChatIcon } from '@chakra-ui/icons';
|
||||
import CommentItem from './CommentItem';
|
||||
|
||||
const CommentList = ({ comments, loading }) => {
|
||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const emptyBgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||||
<Text fontSize="sm" color={emptyTextColor}>
|
||||
加载评论中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (!comments || comments.length === 0) {
|
||||
return (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
bg={emptyBgColor}
|
||||
borderRadius="full"
|
||||
>
|
||||
<ChatIcon boxSize={6} color={emptyTextColor} />
|
||||
</Box>
|
||||
<Text fontSize="sm" color={emptyTextColor}>
|
||||
还没有评论,快来发表第一条吧~
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 评论列表
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentList;
|
||||
245
src/components/EventCommentSection/EventCommentSection.tsx
Normal file
245
src/components/EventCommentSection/EventCommentSection.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// src/components/EventCommentSection/EventCommentSection.tsx
|
||||
/**
|
||||
* 事件评论区主组件(TypeScript 版本)
|
||||
* 功能:整合评论列表 + 评论输入框,管理评论数据
|
||||
* 使用 usePagination Hook 实现分页功能
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Badge,
|
||||
HStack,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Button,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import type { Comment, CreateCommentParams } from '@/types';
|
||||
import type { PaginationLoadResult } from '@/types';
|
||||
import CommentList from './CommentList';
|
||||
import CommentInput from './CommentInput';
|
||||
|
||||
/**
|
||||
* 组件 Props
|
||||
*/
|
||||
interface EventCommentSectionProps {
|
||||
/** 事件 ID */
|
||||
eventId: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件评论区组件
|
||||
*/
|
||||
const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) => {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 评论输入状态
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载评论数据的函数
|
||||
* @param page 页码
|
||||
* @param append 是否追加到已有数据
|
||||
* @returns 分页响应数据
|
||||
*/
|
||||
const loadCommentsFunction = useCallback(
|
||||
async (page: number, append: boolean): Promise<PaginationLoadResult<Comment>> => {
|
||||
try {
|
||||
const result = await eventService.getPosts(
|
||||
eventId,
|
||||
'latest',
|
||||
page,
|
||||
5 // 每页 5 条评论
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('EventCommentSection', '评论加载成功', {
|
||||
eventId,
|
||||
page,
|
||||
count: result.data?.length || 0,
|
||||
total: result.pagination?.total || 0,
|
||||
append,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data || [],
|
||||
pagination: result.pagination,
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.message || '加载评论失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('EventCommentSection', 'loadCommentsFunction', error, {
|
||||
eventId,
|
||||
page,
|
||||
});
|
||||
toast({
|
||||
title: '加载评论失败',
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[eventId, toast]
|
||||
);
|
||||
|
||||
// 使用 usePagination Hook
|
||||
const {
|
||||
data: comments,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
totalCount,
|
||||
loadMore,
|
||||
setData: setComments,
|
||||
setTotalCount,
|
||||
} = usePagination<Comment>(loadCommentsFunction, {
|
||||
pageSize: 5,
|
||||
autoLoad: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 发表评论
|
||||
*/
|
||||
const handleSubmitComment = useCallback(async () => {
|
||||
if (!commentText.trim()) {
|
||||
toast({
|
||||
title: '请输入评论内容',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const params: CreateCommentParams = {
|
||||
content: commentText.trim(),
|
||||
content_type: 'text',
|
||||
};
|
||||
|
||||
const result = await eventService.createPost(eventId, params);
|
||||
|
||||
if (result.success) {
|
||||
// 乐观更新:立即将新评论添加到本地 state,避免重新加载导致的闪烁
|
||||
const newComment: Comment = {
|
||||
id: result.data?.id || `comment_optimistic_${Date.now()}`,
|
||||
content: commentText.trim(),
|
||||
content_type: 'text',
|
||||
author: {
|
||||
id: user?.id || 'current_user',
|
||||
username: user?.username || '当前用户',
|
||||
avatar: user?.avatar || null,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
likes_count: 0,
|
||||
is_liked: false,
|
||||
};
|
||||
|
||||
// 将新评论追加到列表末尾(最新评论在底部)
|
||||
setComments((prevComments) => [...prevComments, newComment]);
|
||||
// 总评论数 +1
|
||||
setTotalCount((prevTotal) => prevTotal + 1);
|
||||
|
||||
toast({
|
||||
title: '评论发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
setCommentText(''); // 清空输入框
|
||||
|
||||
logger.info('EventCommentSection', '评论发布成功(乐观更新)', {
|
||||
eventId,
|
||||
content: commentText.trim(),
|
||||
commentId: newComment.id,
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.message || '评论发布失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('EventCommentSection', 'handleSubmitComment', error, { eventId });
|
||||
toast({
|
||||
title: '评论发布失败',
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [eventId, commentText, toast, user, setComments, setTotalCount]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题栏 */}
|
||||
<HStack spacing={3} mb={4} p={3} bg={sectionBg} borderRadius="md">
|
||||
<Heading size="sm" color={headingColor}>
|
||||
讨论区
|
||||
</Heading>
|
||||
<Badge colorScheme="blue" fontSize="sm" borderRadius="full" px={2}>
|
||||
{totalCount} 条评论
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={dividerColor} mb={4} />
|
||||
|
||||
{/* 评论列表 */}
|
||||
<Box mb={4}>
|
||||
<CommentList comments={comments} loading={loading} />
|
||||
</Box>
|
||||
|
||||
{/* 加载更多按钮(仅当有更多评论时显示) */}
|
||||
{hasMore && (
|
||||
<Center mb={4}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
isLoading={loadingMore}
|
||||
loadingText="加载中..."
|
||||
>
|
||||
加载更多评论
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 评论输入框(仅登录用户显示) */}
|
||||
{user && (
|
||||
<Box>
|
||||
<Divider borderColor={dividerColor} mb={4} />
|
||||
<CommentInput
|
||||
value={commentText}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setCommentText(e.target.value)
|
||||
}
|
||||
onSubmit={handleSubmitComment}
|
||||
isSubmitting={submitting}
|
||||
maxLength={500}
|
||||
placeholder="说点什么..."
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCommentSection;
|
||||
10
src/components/EventCommentSection/index.js
Normal file
10
src/components/EventCommentSection/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/components/EventCommentSection/index.js
|
||||
/**
|
||||
* 事件评论区组件统一导出
|
||||
*/
|
||||
|
||||
export { default } from './EventCommentSection';
|
||||
export { default as EventCommentSection } from './EventCommentSection';
|
||||
export { default as CommentList } from './CommentList';
|
||||
export { default as CommentItem } from './CommentItem';
|
||||
export { default as CommentInput } from './CommentInput';
|
||||
Reference in New Issue
Block a user