个股论坛重做
This commit is contained in:
@@ -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', []),
|
||||
|
||||
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';
|
||||
|
||||
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<MessageInputProps> = ({
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
@@ -90,17 +101,74 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
}
|
||||
}, [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 => ``).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<MessageInputProps> = ({
|
||||
|
||||
setContent('');
|
||||
setAttachments([]);
|
||||
setImages([]);
|
||||
onMessageSent?.(newMessage);
|
||||
|
||||
if (slowMode > 0) {
|
||||
@@ -132,7 +201,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
} 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<MessageInputProps> = ({
|
||||
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) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 移除图片
|
||||
const removeImage = (filename: string) => {
|
||||
setImages(prev => prev.filter(img => img.filename !== filename));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 回复提示 */}
|
||||
@@ -233,6 +315,68 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
)}
|
||||
</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>
|
||||
{attachments.length > 0 && (
|
||||
@@ -276,15 +420,11 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<Progress
|
||||
value={uploadProgress}
|
||||
size="xs"
|
||||
colorScheme="purple"
|
||||
mb={2}
|
||||
borderRadius="full"
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
/>
|
||||
{uploadingCount > 0 && (
|
||||
<Flex align="center" mb={2} px={2}>
|
||||
<Progress size="xs" isIndeterminate colorScheme="purple" flex={1} borderRadius="full" />
|
||||
<Text fontSize="xs" color="gray.500" ml={2}>上传中...</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 输入框容器 */}
|
||||
@@ -328,7 +468,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
justifyContent="flex-start"
|
||||
color="gray.300"
|
||||
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
上传图片
|
||||
</Button>
|
||||
@@ -359,6 +499,16 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 隐藏的图片输入 */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleImageSelect}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -366,7 +516,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
||||
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<MessageInputProps> = ({
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -127,9 +127,21 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染消息内容(处理 @用户 和 $股票)
|
||||
// 渲染消息内容(处理 @用户、$股票 和图片)
|
||||
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) {
|
||||
@@ -148,26 +160,33 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
<Box
|
||||
color={textColor}
|
||||
whiteSpace="pre-wrap"
|
||||
wordBreak="break-word"
|
||||
sx={{
|
||||
'.mention': {
|
||||
bg: mentionBg,
|
||||
color: 'blue.500',
|
||||
color: 'blue.400',
|
||||
borderRadius: 'sm',
|
||||
px: 1,
|
||||
fontWeight: 'semibold',
|
||||
},
|
||||
'.stock-mention': {
|
||||
bg: 'orange.100',
|
||||
color: 'orange.600',
|
||||
bg: 'rgba(251, 146, 60, 0.2)',
|
||||
color: 'orange.300',
|
||||
borderRadius: 'sm',
|
||||
px: 1,
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.message-image, img': {
|
||||
maxWidth: '400px',
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '8px',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user