个股论坛重做

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():
"""
上传图片
返回图片 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:

View File

@@ -24,9 +24,12 @@ interface PostCardProps {
}
const PostCard: React.FC<PostCardProps> = ({ 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<PostCardProps> = ({ post, onClick }) => {
noOfLines={2}
mb={2}
>
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)}
{post.content
.replace(/<[^>]*>/g, '') // 移除 HTML 标签
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[图片]') // 将 Markdown 图片替换为 [图片]
.slice(0, 150)}
</Text>
{/* 标签 */}

View File

@@ -60,12 +60,12 @@ const PostDetail: React.FC<PostDetailProps> = ({ 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<PostDetailProps> = ({ 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<PostDetailProps> = ({ post, onBack }) => {
icon={<MdArrowBack />}
variant="ghost"
mr={2}
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
onClick={onBack}
/>
<Text fontWeight="bold" flex={1} isTruncated>
<Text fontWeight="bold" flex={1} isTruncated color="white">
{post.title}
</Text>
</Flex>
@@ -189,7 +194,7 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
{/* 帖子内容 */}
<Box p={6} bg={bgColor}>
{/* 标题 */}
<Text fontSize="2xl" fontWeight="bold" mb={4}>
<Text fontSize="2xl" fontWeight="bold" mb={4} color="white">
{post.title}
</Text>
@@ -200,9 +205,10 @@ const PostDetail: React.FC<PostDetailProps> = ({ 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))"
/>
<Box>
<Text fontWeight="semibold">{post.authorName}</Text>
<Text fontWeight="semibold" color="purple.300">{post.authorName}</Text>
<Text fontSize="sm" color={mutedColor}>
{formatTime(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 && (
<HStack spacing={2} mb={4} flexWrap="wrap">
{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>
))}
@@ -229,27 +242,42 @@ const PostDetail: React.FC<PostDetailProps> = ({ 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,
'<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={{
'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<PostDetailProps> = ({ post, onBack }) => {
leftIcon={<MdThumbUp />}
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}
</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 leftIcon={<MdShare />} size="sm" variant="ghost">
<Button
leftIcon={<MdShare />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
>
</Button>
</HStack>
@@ -290,6 +332,8 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
icon={<MdMoreVert />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
/>
</Flex>
</Box>
@@ -298,20 +342,20 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
{/* 回复区域 */}
<Box p={4} bg={bgColor}>
<Text fontWeight="bold" mb={4}>
<Text fontWeight="bold" mb={4} color="white">
({post.replyCount})
</Text>
{loading ? (
<Flex justify="center" py={8}>
<Spinner />
<Spinner color="purple.400" />
</Flex>
) : replies.length === 0 ? (
<Flex justify="center" py={8}>
<Text color={mutedColor}></Text>
</Flex>
) : (
<VStack spacing={0} align="stretch" divider={<Divider />}>
<VStack spacing={0} align="stretch" divider={<Divider borderColor={borderColor} />}>
{replies.map(reply => (
<ReplyItem
key={reply.id}
@@ -328,6 +372,8 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
<Button
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
isLoading={loadingMore}
onClick={() => loadReplies(page + 1, true)}
>
@@ -347,14 +393,21 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
align="center"
mb={2}
p={2}
bg={bgColor}
bg="rgba(255, 255, 255, 0.05)"
borderRadius="md"
fontSize="sm"
color="gray.300"
>
<Text flex={1} isTruncated>
<strong>{replyTo.authorName}</strong>: {replyTo.content.slice(0, 50)}
</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>
</Flex>
@@ -368,11 +421,17 @@ const PostDetail: React.FC<PostDetailProps> = ({ 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)' }}
/>
<IconButton
aria-label="发送"
icon={<MdSend />}
colorScheme="blue"
colorScheme="purple"
isLoading={submitting}
isDisabled={!replyContent.trim()}
onClick={handleSubmitReply}
@@ -388,7 +447,7 @@ const PostDetail: React.FC<PostDetailProps> = ({ post, onBack }) => {
borderColor={borderColor}
bg={headerBg}
>
<MdLock />
<MdLock color="gray" />
<Text ml={2} color={mutedColor}>
</Text>

View File

@@ -110,11 +110,27 @@ export const getMessages = async (
): Promise<PaginatedResponse<Message>> => {
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' },