Files
vf_react/src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx
2026-01-06 13:07:41 +08:00

506 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 单条消息组件
* Discord 风格的消息展示
*/
import React, { useState } from 'react';
import {
Box,
Flex,
Text,
Avatar,
IconButton,
HStack,
Tooltip,
Menu,
MenuButton,
MenuList,
MenuItem,
Image,
Badge,
} from '@chakra-ui/react';
import {
MdReply,
MdEmojiEmotions,
MdMoreVert,
MdPushPin,
MdDelete,
MdEdit,
MdForum,
} from 'react-icons/md';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { Message, Embed, AdminRole } from '../../../types';
import { useAdmin } from '../../../contexts/AdminContext';
import { deleteMessage, togglePinMessage } from '../../../services/communityService';
import ReactionBar from './ReactionBar';
import StockEmbed from '../shared/StockEmbed';
// 角色徽章配置
const ROLE_BADGE_CONFIG: Record<AdminRole, { label: string; bg: string; color: string }> = {
super_admin: { label: '超管', bg: 'linear-gradient(135deg, rgba(239, 68, 68, 0.3), rgba(220, 38, 38, 0.3))', color: 'red.300' },
owner: { label: '频主', bg: 'linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(245, 158, 11, 0.3))', color: 'yellow.300' },
admin: { label: '管理', bg: 'linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(37, 99, 235, 0.3))', color: 'blue.300' },
moderator: { label: '版主', bg: 'linear-gradient(135deg, rgba(34, 197, 94, 0.3), rgba(22, 163, 74, 0.3))', color: 'green.300' },
};
interface MessageItemProps {
message: Message;
showAvatar: boolean;
onReply: (message: Message) => void;
onThreadOpen?: (threadId: string) => void;
}
const MessageItem: React.FC<MessageItemProps> = ({
message,
showAvatar,
onReply,
onThreadOpen,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isPinning, setIsPinning] = useState(false);
// 管理员权限
const {
getUserAdminRole,
canDeleteMessages,
canPinMessages,
} = useAdmin();
// 获取消息作者的管理员角色(包括超级管理员)
const authorRole = getUserAdminRole(message.authorId);
// 获取显示的角色徽章
const roleBadge = authorRole ? ROLE_BADGE_CONFIG[authorRole] : null;
// 深色主题颜色HeroUI 风格)
const hoverBg = 'rgba(255, 255, 255, 0.05)';
const textColor = 'gray.100';
const mutedColor = 'gray.400';
const mentionBg = 'rgba(59, 130, 246, 0.2)';
const replyBg = 'rgba(255, 255, 255, 0.05)';
// 格式化时间
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return format(date, 'HH:mm', { locale: zhCN });
};
const formatFullTime = (dateStr: string) => {
const date = new Date(dateStr);
return format(date, 'yyyy年M月d日 HH:mm:ss', { locale: zhCN });
};
// 渲染回复引用
const renderReplyTo = () => {
if (!message.replyTo) return null;
return (
<Flex
align="center"
ml={showAvatar ? '52px' : 0}
mb={1}
fontSize="sm"
color={mutedColor}
>
<Box
w="2px"
h="full"
bg="blue.400"
borderRadius="full"
mr={2}
/>
<Avatar size="xs" name={message.replyTo.authorName} mr={1} />
<Text fontWeight="semibold" mr={2}>
{message.replyTo.authorName}
</Text>
<Text isTruncated maxW="300px">
{message.replyTo.contentPreview || '点击查看消息'}
</Text>
</Flex>
);
};
// 渲染消息内容(处理 @用户 和 $股票)
const renderContent = () => {
let content = message.content;
// 高亮 @用户
if (message.mentionedUsers && message.mentionedUsers.length > 0) {
message.mentionedUsers.forEach(userId => {
const regex = new RegExp(`@${userId}`, 'g');
content = content.replace(regex, `<span class="mention">@${userId}</span>`);
});
}
// 高亮 $股票
if (message.mentionedStocks && message.mentionedStocks.length > 0) {
message.mentionedStocks.forEach(stock => {
const regex = new RegExp(`\\$${stock}`, 'g');
content = content.replace(regex, `<span class="stock-mention">$${stock}</span>`);
});
}
return (
<Text
color={textColor}
whiteSpace="pre-wrap"
wordBreak="break-word"
sx={{
'.mention': {
bg: mentionBg,
color: 'blue.500',
borderRadius: 'sm',
px: 1,
fontWeight: 'semibold',
},
'.stock-mention': {
bg: 'orange.100',
color: 'orange.600',
borderRadius: 'sm',
px: 1,
fontWeight: 'semibold',
cursor: 'pointer',
},
}}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
};
// 渲染附件
const renderAttachments = () => {
if (!message.attachments || message.attachments.length === 0) return null;
return (
<HStack spacing={2} mt={2} flexWrap="wrap">
{message.attachments.map(attachment => (
<Box key={attachment.id}>
{attachment.type === 'image' ? (
<Image
src={attachment.url}
alt={attachment.filename}
maxH="300px"
maxW="400px"
borderRadius="md"
cursor="pointer"
onClick={() => window.open(attachment.url, '_blank')}
/>
) : (
<Box
p={3}
bg={replyBg}
borderRadius="md"
cursor="pointer"
onClick={() => window.open(attachment.url, '_blank')}
>
<Text fontSize="sm" fontWeight="semibold">
📎 {attachment.filename}
</Text>
<Text fontSize="xs" color={mutedColor}>
{(attachment.size / 1024).toFixed(1)} KB
</Text>
</Box>
)}
</Box>
))}
</HStack>
);
};
// 渲染嵌入内容(股票卡片等)
const renderEmbeds = () => {
if (!message.embeds || message.embeds.length === 0) return null;
return (
<Box mt={2}>
{message.embeds.map((embed, index) => {
if (embed.type === 'stock' && embed.stockSymbol) {
return (
<StockEmbed
key={index}
symbol={embed.stockSymbol}
name={embed.stockName}
price={embed.stockPrice}
change={embed.stockChange}
changePercent={embed.stockChangePercent}
/>
);
}
// 链接预览
if (embed.type === 'link') {
return (
<Box
key={index}
p={3}
borderLeftWidth="4px"
borderLeftColor="blue.400"
bg={replyBg}
borderRadius="md"
maxW="500px"
>
{embed.title && (
<Text fontWeight="semibold" color="blue.500" mb={1}>
{embed.title}
</Text>
)}
{embed.description && (
<Text fontSize="sm" color={mutedColor} noOfLines={2}>
{embed.description}
</Text>
)}
</Box>
);
}
return null;
})}
</Box>
);
};
// 处理删除消息
const handleDelete = async () => {
if (isDeleting) return;
setIsDeleting(true);
try {
await deleteMessage(message.id);
// 消息会通过 WebSocket 事件从列表中移除
} catch (error) {
console.error('删除消息失败:', error);
} finally {
setIsDeleting(false);
}
};
// 处理置顶/取消置顶
const handleTogglePin = async () => {
if (isPinning) return;
setIsPinning(true);
try {
await togglePinMessage(message.id, message.isPinned);
// 消息会通过 WebSocket 事件更新
} catch (error) {
console.error('置顶操作失败:', error);
} finally {
setIsPinning(false);
}
};
// 渲染操作栏
const renderActions = () => {
if (!isHovered) return null;
return (
<HStack
position="absolute"
top="-10px"
right="8px"
bg="rgba(17, 24, 39, 0.95)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
p={1}
spacing={0}
>
<Tooltip label="添加表情">
<IconButton
aria-label="添加表情"
icon={<MdEmojiEmotions />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'yellow.400' }}
/>
</Tooltip>
<Tooltip label="回复">
<IconButton
aria-label="回复"
icon={<MdReply />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'purple.400' }}
onClick={() => onReply(message)}
/>
</Tooltip>
<Tooltip label="创建讨论串">
<IconButton
aria-label="创建讨论串"
icon={<MdForum />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'blue.400' }}
onClick={() => onThreadOpen?.(message.id)}
/>
</Tooltip>
<Menu>
<MenuButton
as={IconButton}
aria-label="更多"
icon={<MdMoreVert />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
/>
<MenuList
bg="rgba(17, 24, 39, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 10px 40px rgba(0, 0, 0, 0.4)"
>
{/* 置顶消息 - 需要管理权限 */}
{canPinMessages && (
<MenuItem
icon={<MdPushPin />}
bg="transparent"
_hover={{ bg: 'whiteAlpha.100' }}
color="gray.300"
onClick={handleTogglePin}
isDisabled={isPinning}
>
{message.isPinned ? '取消置顶' : '置顶消息'}
</MenuItem>
)}
<MenuItem icon={<MdEdit />} bg="transparent" _hover={{ bg: 'whiteAlpha.100' }} color="gray.300"></MenuItem>
{/* 删除消息 - 需要管理权限 */}
{canDeleteMessages && (
<MenuItem
icon={<MdDelete />}
bg="transparent"
_hover={{ bg: 'rgba(239, 68, 68, 0.2)' }}
color="red.400"
onClick={handleDelete}
isDisabled={isDeleting}
>
{isDeleting ? '删除中...' : '删除'}
</MenuItem>
)}
</MenuList>
</Menu>
</HStack>
);
};
return (
<Box
position="relative"
py={showAvatar ? 2 : 0.5}
px={2}
mx={-2}
borderRadius="md"
bg={isHovered ? hoverBg : 'transparent'}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 回复引用 */}
{renderReplyTo()}
<Flex>
{/* 头像区域 */}
{showAvatar ? (
<Avatar
size="sm"
name={message.authorName}
src={message.authorAvatar}
mr={3}
mt={1}
/>
) : (
<Box w="40px" mr={3} />
)}
{/* 消息内容 */}
<Box flex={1}>
{/* 用户名和时间(仅在显示头像时) */}
{showAvatar && (
<HStack spacing={2} mb={1}>
<Text fontWeight="semibold" color={textColor}>
{message.authorName}
</Text>
{/* 管理员角色徽章 */}
{roleBadge && (
<Badge
bg={roleBadge.bg}
color={roleBadge.color}
fontSize="2xs"
px={1.5}
py={0.5}
borderRadius="md"
fontWeight="bold"
textTransform="none"
>
{roleBadge.label}
</Badge>
)}
<Tooltip label={formatFullTime(message.createdAt)}>
<Text fontSize="xs" color={mutedColor}>
{formatTime(message.createdAt)}
</Text>
</Tooltip>
{message.isPinned && (
<Badge
bg="rgba(59, 130, 246, 0.2)"
color="blue.300"
fontSize="2xs"
px={1.5}
borderRadius="md"
>
</Badge>
)}
{message.isEdited && (
<Text fontSize="xs" color={mutedColor}>
()
</Text>
)}
</HStack>
)}
{/* 消息文本 */}
{renderContent()}
{/* 附件 */}
{renderAttachments()}
{/* 嵌入内容 */}
{renderEmbeds()}
{/* 表情反应 */}
{message.reactions && Object.keys(message.reactions).length > 0 && (
<ReactionBar
reactions={message.reactions}
messageId={message.id}
/>
)}
</Box>
{/* 仅时间(不显示头像时) */}
{!showAvatar && (
<Tooltip label={formatFullTime(message.createdAt)}>
<Text
fontSize="xs"
color={mutedColor}
opacity={isHovered ? 1 : 0}
transition="opacity 0.2s"
ml={2}
alignSelf="center"
>
{formatTime(message.createdAt)}
</Text>
</Tooltip>
)}
</Flex>
{/* 操作栏 */}
{renderActions()}
</Box>
);
};
export default MessageItem;