个股论坛重做

This commit is contained in:
2026-01-06 16:08:16 +08:00
parent 2ec62893f0
commit 2c46beb58a
5 changed files with 345 additions and 29 deletions

View File

@@ -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
View 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"

View File

@@ -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 => `![图片](${img.url})`).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>

View File

@@ -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 }}
/> />

View File

@@ -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,