Merge branch 'feature_bugfix/251113_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251113_ui

This commit is contained in:
2025-11-15 09:11:57 +08:00
28 changed files with 2001 additions and 41 deletions

View 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;

View 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;

View 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;

View 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;

View 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';