个股论坛重做

This commit is contained in:
2026-01-06 15:15:14 +08:00
parent 463e86c2a7
commit bea9b11184
4 changed files with 137 additions and 69 deletions

View File

@@ -1226,8 +1226,10 @@ def allowed_file(filename):
def upload_image(): def upload_image():
""" """
上传图片 上传图片
返回图片 URL可用于帖子内容中 返回 base64 编码的图片数据,可直接嵌入帖子内容中存储到 ES
""" """
import base64
try: try:
if 'file' not in request.files: if 'file' not in request.files:
return api_error('没有选择文件', 400) return api_error('没有选择文件', 400)
@@ -1240,35 +1242,36 @@ def upload_image():
if not allowed_file(file.filename): if not allowed_file(file.filename):
return api_error('不支持的文件格式,仅支持 PNG、JPG、GIF、WebP', 400) return api_error('不支持的文件格式,仅支持 PNG、JPG、GIF、WebP', 400)
# 检查文件大小 # 读取文件内容
file.seek(0, 2) # 移动到文件末尾 file_content = file.read()
size = file.tell() size = len(file_content)
file.seek(0) # 回到开头
if size > MAX_FILE_SIZE: if size > MAX_FILE_SIZE:
return api_error('文件大小超过限制(最大 10MB', 400) return api_error('文件大小超过限制(最大 10MB', 400)
# 生成唯一文件名 # 获取文件扩展名和 MIME 类型
ext = file.filename.rsplit('.', 1)[1].lower() 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}" 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({ return api_response({
'url': url, 'url': data_url, # base64 data URL可直接用于 img src
'filename': filename, 'filename': filename,
'size': size, 'size': size,
'type': f'image/{ext}' 'type': mime_type
}) })
except Exception as e: except Exception as e:

View File

@@ -24,9 +24,12 @@ interface PostCardProps {
} }
const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => { const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
// 格式化时间 // 格式化时间 - 将 UTC 时间转换为北京时间
const formatTime = (dateStr: string) => { 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, addSuffix: true,
locale: zhCN, locale: zhCN,
}); });
@@ -87,7 +90,10 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
noOfLines={2} noOfLines={2}
mb={2} mb={2}
> >
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)} {post.content
.replace(/<[^>]*>/g, '') // 移除 HTML 标签
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[图片]') // 将 Markdown 图片替换为 [图片]
.slice(0, 150)}
</Text> </Text>
{/* 标签 */} {/* 标签 */}

View File

@@ -60,12 +60,12 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
// 颜色 // 深色主题颜色HeroUI 风格)
const bgColor = useColorModeValue('white', 'gray.800'); const bgColor = 'rgba(17, 24, 39, 0.95)';
const headerBg = useColorModeValue('gray.50', 'gray.900'); const headerBg = 'rgba(17, 24, 39, 0.98)';
const borderColor = useColorModeValue('gray.200', 'gray.700'); const borderColor = 'rgba(255, 255, 255, 0.1)';
const textColor = useColorModeValue('gray.800', 'gray.100'); const textColor = 'gray.100';
const mutedColor = useColorModeValue('gray.500', 'gray.400'); const mutedColor = 'gray.400';
// 加载回复 // 加载回复
const loadReplies = useCallback(async (pageNum: number = 1, append: boolean = false) => { const loadReplies = useCallback(async (pageNum: number = 1, append: boolean = false) => {
@@ -156,9 +156,12 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
} }
}; };
// 格式化时间 // 格式化时间 - 将 UTC 时间转换为北京时间
const formatTime = (dateStr: string) => { 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 ( return (
@@ -177,9 +180,11 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
icon={<MdArrowBack />} icon={<MdArrowBack />}
variant="ghost" variant="ghost"
mr={2} mr={2}
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onBack} onClick={onBack}
/> />
<Text fontWeight="bold" flex={1} isTruncated> <Text fontWeight="bold" flex={1} isTruncated color="white">
{post.title} {post.title}
</Text> </Text>
</Flex> </Flex>
@@ -189,7 +194,7 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
{/* 帖子内容 */} {/* 帖子内容 */}
<Box p={6} bg={bgColor}> <Box p={6} bg={bgColor}>
{/* 标题 */} {/* 标题 */}
<Text fontSize="2xl" fontWeight="bold" mb={4}> <Text fontSize="2xl" fontWeight="bold" mb={4} color="white">
{post.title} {post.title}
</Text> </Text>
@@ -200,9 +205,10 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
name={post.authorName} name={post.authorName}
src={post.authorAvatar} src={post.authorAvatar}
mr={3} mr={3}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))"
/> />
<Box> <Box>
<Text fontWeight="semibold">{post.authorName}</Text> <Text fontWeight="semibold" color="purple.300">{post.authorName}</Text>
<Text fontSize="sm" color={mutedColor}> <Text fontSize="sm" color={mutedColor}>
{formatTime(post.createdAt)} {formatTime(post.createdAt)}
{post.updatedAt && post.updatedAt !== post.createdAt && ( {post.updatedAt && post.updatedAt !== post.createdAt && (
@@ -216,7 +222,14 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<HStack spacing={2} mb={4} flexWrap="wrap"> <HStack spacing={2} mb={4} flexWrap="wrap">
{post.tags.map(tag => ( {post.tags.map(tag => (
<Tag key={tag} colorScheme="blue" size="sm"> <Tag
key={tag}
size="sm"
bg="rgba(59, 130, 246, 0.15)"
color="blue.300"
border="1px solid"
borderColor="rgba(59, 130, 246, 0.3)"
>
{tag} {tag}
</Tag> </Tag>
))} ))}
@@ -229,27 +242,42 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
fontSize="md" fontSize="md"
lineHeight="1.8" lineHeight="1.8"
color={textColor} 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,
'<img src="$2" alt="$1" style="max-width: 100%; border-radius: 8px; margin: 16px 0;" />'
);
// 将换行转换为 <br>
html = html.replace(/\n/g, '<br />');
return html;
})()
}}
sx={{ sx={{
'p': { mb: 4 }, 'p': { mb: 4 },
'img': { maxW: '100%', borderRadius: 'md', my: 4 }, 'img': { maxW: '100%', borderRadius: 'md', my: 4 },
'a': { color: 'blue.500', textDecoration: 'underline' }, 'a': { color: 'blue.400', textDecoration: 'underline' },
'blockquote': { 'blockquote': {
borderLeftWidth: '4px', borderLeftWidth: '4px',
borderLeftColor: 'blue.400', borderLeftColor: 'purple.400',
pl: 4, pl: 4,
py: 2, py: 2,
my: 4, my: 4,
color: mutedColor, color: mutedColor,
bg: 'rgba(255, 255, 255, 0.03)',
}, },
'code': { 'code': {
bg: 'gray.100', bg: 'rgba(255, 255, 255, 0.1)',
color: 'purple.300',
px: 1, px: 1,
borderRadius: 'sm', borderRadius: 'sm',
fontFamily: 'mono', fontFamily: 'mono',
}, },
'pre': { 'pre': {
bg: 'gray.100', bg: 'rgba(0, 0, 0, 0.3)',
p: 4, p: 4,
borderRadius: 'md', borderRadius: 'md',
overflowX: 'auto', overflowX: 'auto',
@@ -272,15 +300,29 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
leftIcon={<MdThumbUp />} leftIcon={<MdThumbUp />}
size="sm" size="sm"
variant={liked ? 'solid' : 'ghost'} variant={liked ? 'solid' : 'ghost'}
colorScheme={liked ? 'blue' : 'gray'} colorScheme={liked ? 'purple' : 'gray'}
color={liked ? 'white' : 'gray.400'}
_hover={{ bg: 'whiteAlpha.100' }}
onClick={handleLike} onClick={handleLike}
> >
{likeCount} {likeCount}
</Button> </Button>
<Button leftIcon={<MdBookmark />} size="sm" variant="ghost"> <Button
leftIcon={<MdBookmark />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
>
</Button> </Button>
<Button leftIcon={<MdShare />} size="sm" variant="ghost"> <Button
leftIcon={<MdShare />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
>
</Button> </Button>
</HStack> </HStack>
@@ -290,6 +332,8 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
icon={<MdMoreVert />} icon={<MdMoreVert />}
size="sm" size="sm"
variant="ghost" variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
/> />
</Flex> </Flex>
</Box> </Box>
@@ -298,20 +342,20 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
{/* 回复区域 */} {/* 回复区域 */}
<Box p={4} bg={bgColor}> <Box p={4} bg={bgColor}>
<Text fontWeight="bold" mb={4}> <Text fontWeight="bold" mb={4} color="white">
({post.replyCount}) ({post.replyCount})
</Text> </Text>
{loading ? ( {loading ? (
<Flex justify="center" py={8}> <Flex justify="center" py={8}>
<Spinner /> <Spinner color="purple.400" />
</Flex> </Flex>
) : replies.length === 0 ? ( ) : replies.length === 0 ? (
<Flex justify="center" py={8}> <Flex justify="center" py={8}>
<Text color={mutedColor}></Text> <Text color={mutedColor}></Text>
</Flex> </Flex>
) : ( ) : (
<VStack spacing={0} align="stretch" divider={<Divider />}> <VStack spacing={0} align="stretch" divider={<Divider borderColor={borderColor} />}>
{replies.map(reply => ( {replies.map(reply => (
<ReplyItem <ReplyItem
key={reply.id} key={reply.id}
@@ -328,6 +372,8 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
isLoading={loadingMore} isLoading={loadingMore}
onClick={() => loadReplies(page + 1, true)} onClick={() => loadReplies(page + 1, true)}
> >
@@ -347,14 +393,21 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
align="center" align="center"
mb={2} mb={2}
p={2} p={2}
bg={bgColor} bg="rgba(255, 255, 255, 0.05)"
borderRadius="md" borderRadius="md"
fontSize="sm" fontSize="sm"
color="gray.300"
> >
<Text flex={1} isTruncated> <Text flex={1} isTruncated>
<strong>{replyTo.authorName}</strong>: {replyTo.content.slice(0, 50)} <strong>{replyTo.authorName}</strong>: {replyTo.content.slice(0, 50)}
</Text> </Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}> <Button
size="xs"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => setReplyTo(null)}
>
</Button> </Button>
</Flex> </Flex>
@@ -368,11 +421,17 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
resize="none" resize="none"
rows={2} rows={2}
mr={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)' }}
/> />
<IconButton <IconButton
aria-label="发送" aria-label="发送"
icon={<MdSend />} icon={<MdSend />}
colorScheme="blue" colorScheme="purple"
isLoading={submitting} isLoading={submitting}
isDisabled={!replyContent.trim()} isDisabled={!replyContent.trim()}
onClick={handleSubmitReply} onClick={handleSubmitReply}
@@ -388,7 +447,7 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
borderColor={borderColor} borderColor={borderColor}
bg={headerBg} bg={headerBg}
> >
<MdLock /> <MdLock color="gray" />
<Text ml={2} color={mutedColor}> <Text ml={2} color={mutedColor}>
</Text> </Text>

View File

@@ -110,11 +110,27 @@ export const getMessages = async (
): Promise<PaginatedResponse<Message>> => { ): Promise<PaginatedResponse<Message>> => {
const { before, after, limit = 50 } = options; 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 = { const esBody: any = {
query: { query: {
match: { bool: {
channel_id: channelId, must: mustClauses,
must_not: [
{ match: { is_deleted: true } },
],
}, },
}, },
sort: [ sort: [
@@ -123,22 +139,6 @@ export const getMessages = async (
size: limit, 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`, { const response = await fetch(`${ES_API_BASE}/community_messages/_search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },