个股论坛重做
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 标签 */}
|
||||
|
||||
@@ -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;
|
||||
// 匹配  格式,支持 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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user