From 2c46beb58a939cb25e7cffe532c5694ab9818e26 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Tue, 6 Jan 2026 16:08:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AA=E8=82=A1=E8=AE=BA=E5=9D=9B=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- community_api.py | 4 + es_rebuild_all.sh | 142 +++++++++++++ .../MessageArea/TextChannel/MessageInput.tsx | 194 ++++++++++++++++-- .../MessageArea/TextChannel/MessageItem.tsx | 33 ++- .../services/communityService.ts | 1 + 5 files changed, 345 insertions(+), 29 deletions(-) create mode 100644 es_rebuild_all.sh diff --git a/community_api.py b/community_api.py index 8bcadeba..c63369f7 100644 --- a/community_api.py +++ b/community_api.py @@ -824,6 +824,9 @@ def send_message(channel_id): message_id = generate_id() now = datetime.utcnow() + # 将 Markdown 图片语法转换为 HTML(支持 base64 图片) + content_html = parse_markdown_images(content) + # 构建消息文档 message_doc = { 'id': message_id, @@ -833,6 +836,7 @@ def send_message(channel_id): 'author_name': user['username'], 'author_avatar': user.get('avatar', ''), 'content': content, + 'content_html': content_html, # Markdown 转换后的 HTML 'type': 'text', 'mentioned_users': data.get('mentionedUsers', []), 'mentioned_stocks': data.get('mentionedStocks', []), diff --git a/es_rebuild_all.sh b/es_rebuild_all.sh new file mode 100644 index 00000000..dcbdc475 --- /dev/null +++ b/es_rebuild_all.sh @@ -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" diff --git a/src/views/StockCommunity/components/MessageArea/TextChannel/MessageInput.tsx b/src/views/StockCommunity/components/MessageArea/TextChannel/MessageInput.tsx index 64d27385..63ca9d78 100644 --- a/src/views/StockCommunity/components/MessageArea/TextChannel/MessageInput.tsx +++ b/src/views/StockCommunity/components/MessageArea/TextChannel/MessageInput.tsx @@ -36,10 +36,18 @@ import { } from 'lucide-react'; import { Message } from '../../../types'; -import { sendMessage } from '../../../services/communityService'; +import { sendMessage, uploadImage } from '../../../services/communityService'; import { useAuth } from '@/contexts/AuthContext'; import { GLASS_BLUR } from '@/constants/glassConfig'; +// 上传的图片接口 +interface UploadedImage { + url: string; + filename: string; + uploading?: boolean; + error?: string; +} + interface MessageInputProps { channelId: string; channelName: string; @@ -64,11 +72,14 @@ const MessageInput: React.FC = ({ const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [attachments, setAttachments] = useState([]); + const [images, setImages] = useState([]); + const [uploadingCount, setUploadingCount] = useState(0); const [uploadProgress, setUploadProgress] = useState(0); const [isFocused, setIsFocused] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const imageInputRef = useRef(null); const { user } = useAuth(); const toast = useToast(); @@ -90,17 +101,74 @@ const MessageInput: React.FC = ({ } }, [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 () => { - if (!content.trim() && attachments.length === 0) return; + if (!content.trim() && attachments.length === 0 && images.length === 0) return; if (cooldown > 0) return; + if (uploadingCount > 0) { + toast({ title: '请等待图片上传完成', status: 'warning', duration: 2000 }); + return; + } try { 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 = { channelId, - content: content.trim(), + content: finalContent, replyTo: replyTo ? { messageId: replyTo.id, authorId: replyTo.authorId, @@ -115,6 +183,7 @@ const MessageInput: React.FC = ({ setContent(''); setAttachments([]); + setImages([]); onMessageSent?.(newMessage); if (slowMode > 0) { @@ -132,7 +201,7 @@ const MessageInput: React.FC = ({ } finally { 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[] => { @@ -178,11 +247,24 @@ const MessageInput: React.FC = ({ setAttachments(prev => [...prev, ...files].slice(0, 5)); }; + // 处理图片选择 + const handleImageSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + files.forEach(file => handleUploadImage(file)); + // 清空 input,允许重复选择同一文件 + e.target.value = ''; + }; + // 移除附件 const removeAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); }; + // 移除图片 + const removeImage = (filename: string) => { + setImages(prev => prev.filter(img => img.filename !== filename)); + }; + return ( {/* 回复提示 */} @@ -233,6 +315,68 @@ const MessageInput: React.FC = ({ )} + {/* 图片预览 */} + + {images.length > 0 && ( + + + {images.map((img) => ( + + {img.uploading ? ( + + + + ) : img.error ? ( + + + 失败 + + ) : ( + <> + + } + 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)} + /> + + )} + + ))} + + + )} + + {/* 附件预览 */} {attachments.length > 0 && ( @@ -276,15 +420,11 @@ const MessageInput: React.FC = ({ {/* 上传进度 */} - {uploadProgress > 0 && uploadProgress < 100 && ( - + {uploadingCount > 0 && ( + + + 上传中... + )} {/* 输入框容器 */} @@ -328,7 +468,7 @@ const MessageInput: React.FC = ({ justifyContent="flex-start" color="gray.300" _hover={{ bg: 'whiteAlpha.100', color: 'white' }} - onClick={() => fileInputRef.current?.click()} + onClick={() => imageInputRef.current?.click()} > 上传图片 @@ -359,6 +499,16 @@ const MessageInput: React.FC = ({ + {/* 隐藏的图片输入 */} + + {/* 隐藏的文件输入 */} = ({ multiple hidden onChange={handleFileSelect} - accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" + accept=".pdf,.doc,.docx,.xls,.xlsx" /> {/* 文本输入 */} @@ -444,16 +594,16 @@ const MessageInput: React.FC = ({ icon={} size="sm" borderRadius="lg" - bgGradient={content.trim() || attachments.length > 0 ? 'linear(to-r, purple.500, blue.500)' : 'none'} - bg={content.trim() || attachments.length > 0 ? undefined : 'rgba(255, 255, 255, 0.05)'} - color={content.trim() || attachments.length > 0 ? 'white' : 'gray.500'} + bgGradient={content.trim() || attachments.length > 0 || images.length > 0 ? 'linear(to-r, purple.500, blue.500)' : 'none'} + bg={content.trim() || attachments.length > 0 || images.length > 0 ? undefined : 'rgba(255, 255, 255, 0.05)'} + color={content.trim() || attachments.length > 0 || images.length > 0 ? 'white' : 'gray.500'} 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} _hover={{ - bgGradient: content.trim() || attachments.length > 0 ? 'linear(to-r, purple.600, blue.600)' : 'none', - bg: content.trim() || attachments.length > 0 ? undefined : 'whiteAlpha.100', - boxShadow: content.trim() || attachments.length > 0 ? '0 0 20px rgba(139, 92, 246, 0.4)' : 'none', + bgGradient: content.trim() || attachments.length > 0 || images.length > 0 ? 'linear(to-r, purple.600, blue.600)' : 'none', + bg: content.trim() || attachments.length > 0 || images.length > 0 ? undefined : 'whiteAlpha.100', + boxShadow: content.trim() || attachments.length > 0 || images.length > 0 ? '0 0 20px rgba(139, 92, 246, 0.4)' : 'none', }} /> diff --git a/src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx b/src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx index 2d8a3266..d9e612d6 100644 --- a/src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx +++ b/src/views/StockCommunity/components/MessageArea/TextChannel/MessageItem.tsx @@ -127,9 +127,21 @@ const MessageItem: React.FC = ({ ); }; - // 渲染消息内容(处理 @用户 和 $股票) + // 渲染消息内容(处理 @用户、$股票 和图片) 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, + '$1' + ); + // 将换行转换为
+ content = content.replace(/\n/g, '
'); + } // 高亮 @用户 if (message.mentionedUsers && message.mentionedUsers.length > 0) { @@ -148,26 +160,33 @@ const MessageItem: React.FC = ({ } return ( - diff --git a/src/views/StockCommunity/services/communityService.ts b/src/views/StockCommunity/services/communityService.ts index c2903cf4..83db995f 100644 --- a/src/views/StockCommunity/services/communityService.ts +++ b/src/views/StockCommunity/services/communityService.ts @@ -163,6 +163,7 @@ export const getMessages = async ( authorName: source.author_name, authorAvatar: source.author_avatar, content: source.content, + contentHtml: source.content_html, // 添加 HTML 内容字段 type: source.type, mentionedUsers: source.mentioned_users, mentionedStocks: source.mentioned_stocks,