个股论坛重做
This commit is contained in:
@@ -824,6 +824,9 @@ def send_message(channel_id):
|
|||||||
message_id = generate_id()
|
message_id = generate_id()
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# 将 Markdown 图片语法转换为 HTML(支持 base64 图片)
|
||||||
|
content_html = parse_markdown_images(content)
|
||||||
|
|
||||||
# 构建消息文档
|
# 构建消息文档
|
||||||
message_doc = {
|
message_doc = {
|
||||||
'id': message_id,
|
'id': message_id,
|
||||||
@@ -833,6 +836,7 @@ def send_message(channel_id):
|
|||||||
'author_name': user['username'],
|
'author_name': user['username'],
|
||||||
'author_avatar': user.get('avatar', ''),
|
'author_avatar': user.get('avatar', ''),
|
||||||
'content': content,
|
'content': content,
|
||||||
|
'content_html': content_html, # Markdown 转换后的 HTML
|
||||||
'type': 'text',
|
'type': 'text',
|
||||||
'mentioned_users': data.get('mentionedUsers', []),
|
'mentioned_users': data.get('mentionedUsers', []),
|
||||||
'mentioned_stocks': data.get('mentionedStocks', []),
|
'mentioned_stocks': data.get('mentionedStocks', []),
|
||||||
|
|||||||
142
es_rebuild_all.sh
Normal file
142
es_rebuild_all.sh
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 社区模块 ES 索引重建脚本
|
||||||
|
# 执行方式: bash es_rebuild_all.sh
|
||||||
|
|
||||||
|
ES_HOST="http://222.128.1.157:19200"
|
||||||
|
|
||||||
|
echo "=== 1. 删除旧索引 ==="
|
||||||
|
curl -X DELETE "$ES_HOST/community_forum_posts" 2>/dev/null; echo ""
|
||||||
|
curl -X DELETE "$ES_HOST/community_forum_replies" 2>/dev/null; echo ""
|
||||||
|
curl -X DELETE "$ES_HOST/community_messages" 2>/dev/null; echo ""
|
||||||
|
curl -X DELETE "$ES_HOST/community_notifications" 2>/dev/null; echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 2. 创建帖子索引 community_forum_posts ==="
|
||||||
|
curl -X PUT "$ES_HOST/community_forum_posts" -H "Content-Type: application/json" -d '
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "keyword" },
|
||||||
|
"channel_id": { "type": "keyword" },
|
||||||
|
"author_id": { "type": "keyword" },
|
||||||
|
"author_name": { "type": "keyword" },
|
||||||
|
"author_avatar": { "type": "keyword" },
|
||||||
|
"title": { "type": "text" },
|
||||||
|
"content": { "type": "text" },
|
||||||
|
"content_html": { "type": "text", "index": false },
|
||||||
|
"tags": { "type": "keyword" },
|
||||||
|
"stock_symbols": { "type": "keyword" },
|
||||||
|
"is_pinned": { "type": "boolean" },
|
||||||
|
"is_locked": { "type": "boolean" },
|
||||||
|
"is_deleted": { "type": "boolean" },
|
||||||
|
"reply_count": { "type": "integer" },
|
||||||
|
"view_count": { "type": "integer" },
|
||||||
|
"like_count": { "type": "integer" },
|
||||||
|
"last_reply_at": { "type": "date" },
|
||||||
|
"last_reply_by": { "type": "keyword" },
|
||||||
|
"created_at": { "type": "date" },
|
||||||
|
"updated_at": { "type": "date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 3. 创建回复索引 community_forum_replies ==="
|
||||||
|
curl -X PUT "$ES_HOST/community_forum_replies" -H "Content-Type: application/json" -d '
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "keyword" },
|
||||||
|
"post_id": { "type": "keyword" },
|
||||||
|
"channel_id": { "type": "keyword" },
|
||||||
|
"author_id": { "type": "keyword" },
|
||||||
|
"author_name": { "type": "keyword" },
|
||||||
|
"author_avatar": { "type": "keyword" },
|
||||||
|
"content": { "type": "text" },
|
||||||
|
"content_html": { "type": "text", "index": false },
|
||||||
|
"reply_to": { "type": "object", "enabled": false },
|
||||||
|
"reactions": { "type": "object", "enabled": false },
|
||||||
|
"like_count": { "type": "integer" },
|
||||||
|
"is_solution": { "type": "boolean" },
|
||||||
|
"is_deleted": { "type": "boolean" },
|
||||||
|
"created_at": { "type": "date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 4. 创建消息索引 community_messages ==="
|
||||||
|
curl -X PUT "$ES_HOST/community_messages" -H "Content-Type: application/json" -d '
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "keyword" },
|
||||||
|
"channel_id": { "type": "keyword" },
|
||||||
|
"author_id": { "type": "keyword" },
|
||||||
|
"author_name": { "type": "keyword" },
|
||||||
|
"author_avatar": { "type": "keyword" },
|
||||||
|
"content": { "type": "text" },
|
||||||
|
"content_html": { "type": "text", "index": false },
|
||||||
|
"mentioned_users": { "type": "keyword" },
|
||||||
|
"mentioned_stocks": { "type": "keyword" },
|
||||||
|
"attachments": { "type": "object", "enabled": false },
|
||||||
|
"embeds": { "type": "object", "enabled": false },
|
||||||
|
"reactions": { "type": "object", "enabled": false },
|
||||||
|
"reply_to": { "type": "object", "enabled": false },
|
||||||
|
"thread_id": { "type": "keyword" },
|
||||||
|
"is_pinned": { "type": "boolean" },
|
||||||
|
"is_edited": { "type": "boolean" },
|
||||||
|
"is_deleted": { "type": "boolean" },
|
||||||
|
"created_at": { "type": "date" },
|
||||||
|
"updated_at": { "type": "date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 5. 创建通知索引 community_notifications ==="
|
||||||
|
curl -X PUT "$ES_HOST/community_notifications" -H "Content-Type: application/json" -d '
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "keyword" },
|
||||||
|
"user_id": { "type": "keyword" },
|
||||||
|
"type": { "type": "keyword" },
|
||||||
|
"title": { "type": "text" },
|
||||||
|
"content": { "type": "text" },
|
||||||
|
"content_html": { "type": "text", "index": false },
|
||||||
|
"from_user_id": { "type": "keyword" },
|
||||||
|
"from_user_name": { "type": "keyword" },
|
||||||
|
"from_user_avatar": { "type": "keyword" },
|
||||||
|
"related_id": { "type": "keyword" },
|
||||||
|
"related_type": { "type": "keyword" },
|
||||||
|
"channel_id": { "type": "keyword" },
|
||||||
|
"is_read": { "type": "boolean" },
|
||||||
|
"created_at": { "type": "date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 完成!验证索引 ==="
|
||||||
|
curl -X GET "$ES_HOST/_cat/indices/community_*?v"
|
||||||
@@ -36,10 +36,18 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Message } from '../../../types';
|
import { Message } from '../../../types';
|
||||||
import { sendMessage } from '../../../services/communityService';
|
import { sendMessage, uploadImage } from '../../../services/communityService';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||||
|
|
||||||
|
// 上传的图片接口
|
||||||
|
interface UploadedImage {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
@@ -64,11 +72,14 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
const [attachments, setAttachments] = useState<File[]>([]);
|
||||||
|
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||||
|
const [uploadingCount, setUploadingCount] = useState(0);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -90,17 +101,74 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
}
|
}
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
const handleUploadImage = useCallback(async (file: File) => {
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast({ title: '只能上传图片文件', status: 'warning', duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小(10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast({ title: '图片大小不能超过 10MB', status: 'warning', duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加临时占位
|
||||||
|
const tempId = `temp_${Date.now()}`;
|
||||||
|
setImages(prev => [...prev, { url: '', filename: tempId, uploading: true }]);
|
||||||
|
setUploadingCount(prev => prev + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(file);
|
||||||
|
|
||||||
|
// 替换临时占位为实际结果
|
||||||
|
setImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.filename === tempId
|
||||||
|
? { url: result.url, filename: result.filename, uploading: false }
|
||||||
|
: img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// 标记上传失败
|
||||||
|
setImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.filename === tempId
|
||||||
|
? { ...img, uploading: false, error: error.message }
|
||||||
|
: img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast({ title: '图片上传失败', status: 'error', duration: 3000 });
|
||||||
|
} finally {
|
||||||
|
setUploadingCount(prev => prev - 1);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!content.trim() && attachments.length === 0) return;
|
if (!content.trim() && attachments.length === 0 && images.length === 0) return;
|
||||||
if (cooldown > 0) return;
|
if (cooldown > 0) return;
|
||||||
|
if (uploadingCount > 0) {
|
||||||
|
toast({ title: '请等待图片上传完成', status: 'warning', duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
|
// 将图片 URL 添加到消息内容末尾(Markdown 格式)
|
||||||
|
let finalContent = content.trim();
|
||||||
|
const uploadedImages = images.filter(img => !img.error && !img.uploading && img.url);
|
||||||
|
if (uploadedImages.length > 0) {
|
||||||
|
const imageMarkdown = uploadedImages.map(img => ``).join('\n');
|
||||||
|
finalContent = finalContent ? `${finalContent}\n\n${imageMarkdown}` : imageMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
const messageData = {
|
const messageData = {
|
||||||
channelId,
|
channelId,
|
||||||
content: content.trim(),
|
content: finalContent,
|
||||||
replyTo: replyTo ? {
|
replyTo: replyTo ? {
|
||||||
messageId: replyTo.id,
|
messageId: replyTo.id,
|
||||||
authorId: replyTo.authorId,
|
authorId: replyTo.authorId,
|
||||||
@@ -115,6 +183,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
|
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setImages([]);
|
||||||
onMessageSent?.(newMessage);
|
onMessageSent?.(newMessage);
|
||||||
|
|
||||||
if (slowMode > 0) {
|
if (slowMode > 0) {
|
||||||
@@ -132,7 +201,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [content, attachments, channelId, replyTo, slowMode, onMessageSent, toast, cooldown]);
|
}, [content, attachments, images, uploadingCount, channelId, replyTo, slowMode, onMessageSent, toast, cooldown]);
|
||||||
|
|
||||||
// 提取 @用户
|
// 提取 @用户
|
||||||
const extractMentions = (text: string): string[] => {
|
const extractMentions = (text: string): string[] => {
|
||||||
@@ -178,11 +247,24 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
setAttachments(prev => [...prev, ...files].slice(0, 5));
|
setAttachments(prev => [...prev, ...files].slice(0, 5));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理图片选择
|
||||||
|
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
files.forEach(file => handleUploadImage(file));
|
||||||
|
// 清空 input,允许重复选择同一文件
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
// 移除附件
|
// 移除附件
|
||||||
const removeAttachment = (index: number) => {
|
const removeAttachment = (index: number) => {
|
||||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 移除图片
|
||||||
|
const removeImage = (filename: string) => {
|
||||||
|
setImages(prev => prev.filter(img => img.filename !== filename));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* 回复提示 */}
|
{/* 回复提示 */}
|
||||||
@@ -233,6 +315,68 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<HStack spacing={2} mb={2} flexWrap="wrap">
|
||||||
|
{images.map((img) => (
|
||||||
|
<Box
|
||||||
|
key={img.filename}
|
||||||
|
position="relative"
|
||||||
|
w="60px"
|
||||||
|
h="60px"
|
||||||
|
borderRadius="md"
|
||||||
|
overflow="hidden"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={img.error ? 'red.400' : 'rgba(255, 255, 255, 0.1)'}
|
||||||
|
>
|
||||||
|
{img.uploading ? (
|
||||||
|
<Flex w="full" h="full" align="center" justify="center" bg="rgba(0, 0, 0, 0.5)">
|
||||||
|
<Progress size="xs" isIndeterminate colorScheme="purple" w="80%" />
|
||||||
|
</Flex>
|
||||||
|
) : img.error ? (
|
||||||
|
<Flex w="full" h="full" align="center" justify="center" bg="rgba(239, 68, 68, 0.1)" flexDir="column">
|
||||||
|
<Icon as={X} color="red.400" boxSize={4} />
|
||||||
|
<Text fontSize="2xs" color="red.400">失败</Text>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
as="img"
|
||||||
|
src={img.url}
|
||||||
|
alt={img.filename}
|
||||||
|
w="full"
|
||||||
|
h="full"
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
aria-label="删除图片"
|
||||||
|
icon={<X className="w-3 h-3" />}
|
||||||
|
size="xs"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bg="rgba(0, 0, 0, 0.6)"
|
||||||
|
color="white"
|
||||||
|
minW="18px"
|
||||||
|
h="18px"
|
||||||
|
_hover={{ bg: 'red.500' }}
|
||||||
|
onClick={() => removeImage(img.filename)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 附件预览 */}
|
{/* 附件预览 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -276,15 +420,11 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 上传进度 */}
|
{/* 上传进度 */}
|
||||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
{uploadingCount > 0 && (
|
||||||
<Progress
|
<Flex align="center" mb={2} px={2}>
|
||||||
value={uploadProgress}
|
<Progress size="xs" isIndeterminate colorScheme="purple" flex={1} borderRadius="full" />
|
||||||
size="xs"
|
<Text fontSize="xs" color="gray.500" ml={2}>上传中...</Text>
|
||||||
colorScheme="purple"
|
</Flex>
|
||||||
mb={2}
|
|
||||||
borderRadius="full"
|
|
||||||
bg="rgba(255, 255, 255, 0.05)"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 输入框容器 */}
|
{/* 输入框容器 */}
|
||||||
@@ -328,7 +468,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
justifyContent="flex-start"
|
justifyContent="flex-start"
|
||||||
color="gray.300"
|
color="gray.300"
|
||||||
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => imageInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
上传图片
|
上传图片
|
||||||
</Button>
|
</Button>
|
||||||
@@ -359,6 +499,16 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
{/* 隐藏的图片输入 */}
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 隐藏的文件输入 */}
|
{/* 隐藏的文件输入 */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -366,7 +516,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
accept=".pdf,.doc,.docx,.xls,.xlsx"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 文本输入 */}
|
{/* 文本输入 */}
|
||||||
@@ -444,16 +594,16 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||||||
icon={<Send className="w-5 h-5" />}
|
icon={<Send className="w-5 h-5" />}
|
||||||
size="sm"
|
size="sm"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
bgGradient={content.trim() || attachments.length > 0 ? 'linear(to-r, purple.500, blue.500)' : 'none'}
|
bgGradient={content.trim() || attachments.length > 0 || images.length > 0 ? 'linear(to-r, purple.500, blue.500)' : 'none'}
|
||||||
bg={content.trim() || attachments.length > 0 ? undefined : 'rgba(255, 255, 255, 0.05)'}
|
bg={content.trim() || attachments.length > 0 || images.length > 0 ? undefined : 'rgba(255, 255, 255, 0.05)'}
|
||||||
color={content.trim() || attachments.length > 0 ? 'white' : 'gray.500'}
|
color={content.trim() || attachments.length > 0 || images.length > 0 ? 'white' : 'gray.500'}
|
||||||
isLoading={sending}
|
isLoading={sending}
|
||||||
isDisabled={(!content.trim() && attachments.length === 0) || cooldown > 0}
|
isDisabled={(!content.trim() && attachments.length === 0 && images.length === 0) || cooldown > 0 || uploadingCount > 0}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
_hover={{
|
_hover={{
|
||||||
bgGradient: content.trim() || attachments.length > 0 ? 'linear(to-r, purple.600, blue.600)' : 'none',
|
bgGradient: content.trim() || attachments.length > 0 || images.length > 0 ? 'linear(to-r, purple.600, blue.600)' : 'none',
|
||||||
bg: content.trim() || attachments.length > 0 ? undefined : 'whiteAlpha.100',
|
bg: content.trim() || attachments.length > 0 || images.length > 0 ? undefined : 'whiteAlpha.100',
|
||||||
boxShadow: content.trim() || attachments.length > 0 ? '0 0 20px rgba(139, 92, 246, 0.4)' : 'none',
|
boxShadow: content.trim() || attachments.length > 0 || images.length > 0 ? '0 0 20px rgba(139, 92, 246, 0.4)' : 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -127,9 +127,21 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染消息内容(处理 @用户 和 $股票)
|
// 渲染消息内容(处理 @用户、$股票 和图片)
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
let content = message.content;
|
// 优先使用后端解析好的 contentHtml,否则解析原始 content
|
||||||
|
let content = message.contentHtml || message.content;
|
||||||
|
|
||||||
|
// 如果没有 contentHtml,手动解析 Markdown 图片
|
||||||
|
if (!message.contentHtml) {
|
||||||
|
// 将 Markdown 图片语法转换为 HTML
|
||||||
|
content = content.replace(
|
||||||
|
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||||
|
'<img src="$2" alt="$1" class="message-image" />'
|
||||||
|
);
|
||||||
|
// 将换行转换为 <br>
|
||||||
|
content = content.replace(/\n/g, '<br />');
|
||||||
|
}
|
||||||
|
|
||||||
// 高亮 @用户
|
// 高亮 @用户
|
||||||
if (message.mentionedUsers && message.mentionedUsers.length > 0) {
|
if (message.mentionedUsers && message.mentionedUsers.length > 0) {
|
||||||
@@ -148,26 +160,33 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Box
|
||||||
color={textColor}
|
color={textColor}
|
||||||
whiteSpace="pre-wrap"
|
|
||||||
wordBreak="break-word"
|
wordBreak="break-word"
|
||||||
sx={{
|
sx={{
|
||||||
'.mention': {
|
'.mention': {
|
||||||
bg: mentionBg,
|
bg: mentionBg,
|
||||||
color: 'blue.500',
|
color: 'blue.400',
|
||||||
borderRadius: 'sm',
|
borderRadius: 'sm',
|
||||||
px: 1,
|
px: 1,
|
||||||
fontWeight: 'semibold',
|
fontWeight: 'semibold',
|
||||||
},
|
},
|
||||||
'.stock-mention': {
|
'.stock-mention': {
|
||||||
bg: 'orange.100',
|
bg: 'rgba(251, 146, 60, 0.2)',
|
||||||
color: 'orange.600',
|
color: 'orange.300',
|
||||||
borderRadius: 'sm',
|
borderRadius: 'sm',
|
||||||
px: 1,
|
px: 1,
|
||||||
fontWeight: 'semibold',
|
fontWeight: 'semibold',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
|
'.message-image, img': {
|
||||||
|
maxWidth: '400px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginTop: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const getMessages = async (
|
|||||||
authorName: source.author_name,
|
authorName: source.author_name,
|
||||||
authorAvatar: source.author_avatar,
|
authorAvatar: source.author_avatar,
|
||||||
content: source.content,
|
content: source.content,
|
||||||
|
contentHtml: source.content_html, // 添加 HTML 内容字段
|
||||||
type: source.type,
|
type: source.type,
|
||||||
mentionedUsers: source.mentioned_users,
|
mentionedUsers: source.mentioned_users,
|
||||||
mentionedStocks: source.mentioned_stocks,
|
mentionedStocks: source.mentioned_stocks,
|
||||||
|
|||||||
Reference in New Issue
Block a user