506 lines
14 KiB
TypeScript
506 lines
14 KiB
TypeScript
/**
|
||
* 单条消息组件
|
||
* 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;
|