From bea9b111848c7e60e16dbe2897f017721cc11c46 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Tue, 6 Jan 2026 15:15:14 +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 | 43 +++---- .../MessageArea/ForumChannel/PostCard.tsx | 12 +- .../MessageArea/ForumChannel/PostDetail.tsx | 113 +++++++++++++----- .../services/communityService.ts | 38 +++--- 4 files changed, 137 insertions(+), 69 deletions(-) diff --git a/community_api.py b/community_api.py index 5806fb37..90c60713 100644 --- a/community_api.py +++ b/community_api.py @@ -1226,8 +1226,10 @@ def allowed_file(filename): def upload_image(): """ 上传图片 - 返回图片 URL,可用于帖子内容中 + 返回 base64 编码的图片数据,可直接嵌入帖子内容中存储到 ES """ + import base64 + try: if 'file' not in request.files: return api_error('没有选择文件', 400) @@ -1240,35 +1242,36 @@ def upload_image(): if not allowed_file(file.filename): return api_error('不支持的文件格式,仅支持 PNG、JPG、GIF、WebP', 400) - # 检查文件大小 - file.seek(0, 2) # 移动到文件末尾 - size = file.tell() - file.seek(0) # 回到开头 + # 读取文件内容 + file_content = file.read() + size = len(file_content) if size > MAX_FILE_SIZE: return api_error('文件大小超过限制(最大 10MB)', 400) - # 生成唯一文件名 + # 获取文件扩展名和 MIME 类型 ext = file.filename.rsplit('.', 1)[1].lower() + mime_types = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp' + } + mime_type = mime_types.get(ext, 'image/jpeg') + + # 转换为 base64 + base64_data = base64.b64encode(file_content).decode('utf-8') + data_url = f"data:{mime_type};base64,{base64_data}" + + # 生成唯一标识 filename = f"{generate_id()}.{ext}" - # 创建日期目录 - today = datetime.now().strftime('%Y%m%d') - upload_dir = os.path.join(current_app.root_path, UPLOAD_FOLDER, today) - os.makedirs(upload_dir, exist_ok=True) - - # 保存文件 - filepath = os.path.join(upload_dir, filename) - file.save(filepath) - - # 返回访问 URL - url = f"/uploads/community/{today}/{filename}" - return api_response({ - 'url': url, + 'url': data_url, # base64 data URL,可直接用于 img src 'filename': filename, 'size': size, - 'type': f'image/{ext}' + 'type': mime_type }) except Exception as e: diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx index 2994b195..1cbfd31e 100644 --- a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx @@ -24,9 +24,12 @@ interface PostCardProps { } const PostCard: React.FC = ({ post, onClick }) => { - // 格式化时间 + // 格式化时间 - 将 UTC 时间转换为北京时间 const formatTime = (dateStr: string) => { - return formatDistanceToNow(new Date(dateStr), { + const date = new Date(dateStr); + // 后端存储的是 UTC 时间,需要加 8 小时转换为北京时间 + const beijingDate = new Date(date.getTime() + 8 * 60 * 60 * 1000); + return formatDistanceToNow(beijingDate, { addSuffix: true, locale: zhCN, }); @@ -87,7 +90,10 @@ const PostCard: React.FC = ({ post, onClick }) => { noOfLines={2} mb={2} > - {post.content.replace(/<[^>]*>/g, '').slice(0, 150)} + {post.content + .replace(/<[^>]*>/g, '') // 移除 HTML 标签 + .replace(/!\[[^\]]*\]\([^)]+\)/g, '[图片]') // 将 Markdown 图片替换为 [图片] + .slice(0, 150)} {/* 标签 */} diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostDetail.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostDetail.tsx index d168b301..6f88694c 100644 --- a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostDetail.tsx +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostDetail.tsx @@ -60,12 +60,12 @@ const PostDetail: React.FC = ({ post, onBack }) => { const { user } = useAuth(); const toast = useToast(); - // 颜色 - const bgColor = useColorModeValue('white', 'gray.800'); - const headerBg = useColorModeValue('gray.50', 'gray.900'); - const borderColor = useColorModeValue('gray.200', 'gray.700'); - const textColor = useColorModeValue('gray.800', 'gray.100'); - const mutedColor = useColorModeValue('gray.500', 'gray.400'); + // 深色主题颜色(HeroUI 风格) + const bgColor = 'rgba(17, 24, 39, 0.95)'; + const headerBg = 'rgba(17, 24, 39, 0.98)'; + const borderColor = 'rgba(255, 255, 255, 0.1)'; + const textColor = 'gray.100'; + const mutedColor = 'gray.400'; // 加载回复 const loadReplies = useCallback(async (pageNum: number = 1, append: boolean = false) => { @@ -156,9 +156,12 @@ const PostDetail: React.FC = ({ post, onBack }) => { } }; - // 格式化时间 + // 格式化时间 - 将 UTC 时间转换为北京时间 const formatTime = (dateStr: string) => { - return format(new Date(dateStr), 'yyyy年M月d日 HH:mm', { locale: zhCN }); + const date = new Date(dateStr); + // 后端存储的是 UTC 时间,需要加 8 小时转换为北京时间 + const beijingDate = new Date(date.getTime() + 8 * 60 * 60 * 1000); + return format(beijingDate, 'yyyy年M月d日 HH:mm', { locale: zhCN }); }; return ( @@ -177,9 +180,11 @@ const PostDetail: React.FC = ({ post, onBack }) => { icon={} variant="ghost" mr={2} + color="gray.400" + _hover={{ bg: 'whiteAlpha.100', color: 'white' }} onClick={onBack} /> - + {post.title} @@ -189,7 +194,7 @@ const PostDetail: React.FC = ({ post, onBack }) => { {/* 帖子内容 */} {/* 标题 */} - + {post.title} @@ -200,9 +205,10 @@ const PostDetail: React.FC = ({ post, onBack }) => { name={post.authorName} src={post.authorAvatar} mr={3} + bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))" /> - {post.authorName} + {post.authorName} 发布于 {formatTime(post.createdAt)} {post.updatedAt && post.updatedAt !== post.createdAt && ( @@ -216,7 +222,14 @@ const PostDetail: React.FC = ({ post, onBack }) => { {post.tags && post.tags.length > 0 && ( {post.tags.map(tag => ( - + {tag} ))} @@ -229,27 +242,42 @@ const PostDetail: React.FC = ({ post, onBack }) => { fontSize="md" lineHeight="1.8" color={textColor} - dangerouslySetInnerHTML={{ __html: post.contentHtml || post.content }} + dangerouslySetInnerHTML={{ + __html: (() => { + // 将 Markdown 图片语法转换为 HTML img 标签 + let html = post.contentHtml || post.content; + // 匹配 ![alt](url) 格式,支持 base64 data URL + html = html.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + '$1' + ); + // 将换行转换为
+ html = html.replace(/\n/g, '
'); + return html; + })() + }} sx={{ 'p': { mb: 4 }, 'img': { maxW: '100%', borderRadius: 'md', my: 4 }, - 'a': { color: 'blue.500', textDecoration: 'underline' }, + 'a': { color: 'blue.400', textDecoration: 'underline' }, 'blockquote': { borderLeftWidth: '4px', - borderLeftColor: 'blue.400', + borderLeftColor: 'purple.400', pl: 4, py: 2, my: 4, color: mutedColor, + bg: 'rgba(255, 255, 255, 0.03)', }, 'code': { - bg: 'gray.100', + bg: 'rgba(255, 255, 255, 0.1)', + color: 'purple.300', px: 1, borderRadius: 'sm', fontFamily: 'mono', }, 'pre': { - bg: 'gray.100', + bg: 'rgba(0, 0, 0, 0.3)', p: 4, borderRadius: 'md', overflowX: 'auto', @@ -272,15 +300,29 @@ const PostDetail: React.FC = ({ post, onBack }) => { leftIcon={} size="sm" variant={liked ? 'solid' : 'ghost'} - colorScheme={liked ? 'blue' : 'gray'} + colorScheme={liked ? 'purple' : 'gray'} + color={liked ? 'white' : 'gray.400'} + _hover={{ bg: 'whiteAlpha.100' }} onClick={handleLike} > {likeCount} - -
@@ -290,6 +332,8 @@ const PostDetail: React.FC = ({ post, onBack }) => { icon={} size="sm" variant="ghost" + color="gray.400" + _hover={{ bg: 'whiteAlpha.100', color: 'white' }} />
@@ -298,20 +342,20 @@ const PostDetail: React.FC = ({ post, onBack }) => { {/* 回复区域 */} - + 全部回复 ({post.replyCount}) {loading ? ( - + ) : replies.length === 0 ? ( 暂无回复,快来抢沙发! ) : ( - }> + }> {replies.map(reply => ( = ({ post, onBack }) => { @@ -368,11 +421,17 @@ const PostDetail: React.FC = ({ post, onBack }) => { resize="none" rows={2} mr={2} + bg="rgba(255, 255, 255, 0.05)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: 'gray.500' }} + _focus={{ borderColor: 'purple.400', boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)' }} /> } - colorScheme="blue" + colorScheme="purple" isLoading={submitting} isDisabled={!replyContent.trim()} onClick={handleSubmitReply} @@ -388,7 +447,7 @@ const PostDetail: React.FC = ({ post, onBack }) => { borderColor={borderColor} bg={headerBg} > - + 该帖子已锁定,无法回复 diff --git a/src/views/StockCommunity/services/communityService.ts b/src/views/StockCommunity/services/communityService.ts index 6bc8e659..e139c88f 100644 --- a/src/views/StockCommunity/services/communityService.ts +++ b/src/views/StockCommunity/services/communityService.ts @@ -110,11 +110,27 @@ export const getMessages = async ( ): Promise> => { const { before, after, limit = 50 } = options; - // 构建 ES 查询 - 使用最简单的 match_all 先测试 + // 构建 ES 查询 + const mustClauses: any[] = [ + { match: { channel_id: channelId } }, + ]; + + // 分页游标 - 加载更早的消息 + if (before) { + mustClauses.push({ range: { created_at: { lt: before } } }); + } + // 加载更新的消息 + if (after) { + mustClauses.push({ range: { created_at: { gt: after } } }); + } + const esBody: any = { query: { - match: { - channel_id: channelId, + bool: { + must: mustClauses, + must_not: [ + { match: { is_deleted: true } }, + ], }, }, sort: [ @@ -123,22 +139,6 @@ export const getMessages = async ( size: limit, }; - // 分页游标 - if (before) { - esBody.query = { - bool: { - must: [ - { match: { channel_id: channelId } }, - ], - filter: [ - { range: { created_at: { lt: before } } }, - ], - }, - }; - } - - console.log('[getMessages] ES 查询:', JSON.stringify(esBody, null, 2)); - const response = await fetch(`${ES_API_BASE}/community_messages/_search`, { method: 'POST', headers: { 'Content-Type': 'application/json' },