544 lines
16 KiB
JavaScript
544 lines
16 KiB
JavaScript
/**
|
||
* 发帖模态框组件
|
||
* 用于创建新帖子
|
||
*/
|
||
|
||
import React, { useState } from 'react';
|
||
import {
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalBody,
|
||
ModalFooter,
|
||
ModalCloseButton,
|
||
Button,
|
||
Input,
|
||
Textarea,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
Box,
|
||
Image,
|
||
IconButton,
|
||
Tag,
|
||
TagLabel,
|
||
TagCloseButton,
|
||
useToast,
|
||
FormControl,
|
||
FormLabel,
|
||
FormErrorMessage,
|
||
} from '@chakra-ui/react';
|
||
import { ImagePlus, X, Hash } from 'lucide-react';
|
||
import { forumColors } from '@theme/forumTheme';
|
||
import { createPost } from '@services/elasticsearchService';
|
||
import { useAuth } from '@contexts/AuthContext';
|
||
|
||
const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
|
||
const toast = useToast();
|
||
const { user } = useAuth();
|
||
|
||
const [formData, setFormData] = useState({
|
||
title: '',
|
||
content: '',
|
||
images: [],
|
||
tags: [],
|
||
category: '',
|
||
});
|
||
|
||
const [currentTag, setCurrentTag] = useState('');
|
||
const [errors, setErrors] = useState({});
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
// 表单验证
|
||
const validateForm = () => {
|
||
const newErrors = {};
|
||
|
||
if (!formData.title.trim()) {
|
||
newErrors.title = '请输入标题';
|
||
} else if (formData.title.length > 100) {
|
||
newErrors.title = '标题不能超过100个字符';
|
||
}
|
||
|
||
if (!formData.content.trim()) {
|
||
newErrors.content = '请输入内容';
|
||
} else if (formData.content.length > 5000) {
|
||
newErrors.content = '内容不能超过5000个字符';
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
// 压缩图片
|
||
const compressImage = (file) => {
|
||
return new Promise((resolve, reject) => {
|
||
// 检查文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
reject(new Error('请选择图片文件'));
|
||
return;
|
||
}
|
||
|
||
// 检查文件大小(10MB 限制,给压缩留空间)
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
reject(new Error('图片大小不能超过 10MB'));
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
|
||
reader.onload = function(e) {
|
||
const img = document.createElement('img');
|
||
|
||
img.onload = function() {
|
||
try {
|
||
// 创建 canvas 进行压缩
|
||
const canvas = document.createElement('canvas');
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
// 如果图片尺寸过大,等比缩放到最大 1920px
|
||
const maxDimension = 1920;
|
||
if (width > maxDimension || height > maxDimension) {
|
||
if (width > height) {
|
||
height = Math.round((height * maxDimension) / width);
|
||
width = maxDimension;
|
||
} else {
|
||
width = Math.round((width * maxDimension) / height);
|
||
height = maxDimension;
|
||
}
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 转换为 Data URL(JPEG 格式,质量 0.8)
|
||
try {
|
||
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||
|
||
// 计算压缩率
|
||
const originalSize = file.size;
|
||
const compressedSize = Math.round((compressedDataUrl.length * 3) / 4); // Base64 解码后的大小
|
||
|
||
console.log(
|
||
`图片压缩: ${(originalSize / 1024).toFixed(2)}KB -> ${(compressedSize / 1024).toFixed(2)}KB`
|
||
);
|
||
|
||
resolve(compressedDataUrl);
|
||
} catch (error) {
|
||
reject(new Error('图片压缩失败'));
|
||
}
|
||
} catch (error) {
|
||
reject(new Error(`图片处理失败: ${error.message}`));
|
||
}
|
||
};
|
||
|
||
img.onerror = function() {
|
||
reject(new Error('图片加载失败'));
|
||
};
|
||
|
||
img.src = e.target.result;
|
||
};
|
||
|
||
reader.onerror = function() {
|
||
reject(new Error('文件读取失败'));
|
||
};
|
||
|
||
reader.readAsDataURL(file);
|
||
});
|
||
};
|
||
|
||
// 处理图片上传
|
||
const handleImageUpload = async (e) => {
|
||
const files = Array.from(e.target.files);
|
||
|
||
// 清空 input 以支持重复上传同一文件
|
||
e.target.value = '';
|
||
|
||
if (files.length === 0) return;
|
||
|
||
// 检查总数量限制
|
||
if (formData.images.length + files.length > 9) {
|
||
toast({
|
||
title: '图片数量超限',
|
||
description: '最多只能上传 9 张图片',
|
||
status: 'warning',
|
||
duration: 3000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 逐个处理图片,而不是使用 Promise.all
|
||
const compressedImages = [];
|
||
let hasError = false;
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
try {
|
||
const compressed = await compressImage(files[i]);
|
||
compressedImages.push(compressed);
|
||
} catch (error) {
|
||
console.error('图片压缩失败:', error);
|
||
hasError = true;
|
||
toast({
|
||
title: '图片处理失败',
|
||
description: error.message || `第 ${i + 1} 张图片处理失败`,
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
break; // 遇到错误就停止
|
||
}
|
||
}
|
||
|
||
// 如果有成功压缩的图片,添加到表单
|
||
if (compressedImages.length > 0) {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
images: [...prev.images, ...compressedImages],
|
||
}));
|
||
|
||
if (!hasError) {
|
||
toast({
|
||
title: '上传成功',
|
||
description: `成功添加 ${compressedImages.length} 张图片`,
|
||
status: 'success',
|
||
duration: 2000,
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 移除图片
|
||
const removeImage = (index) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
images: prev.images.filter((_, i) => i !== index),
|
||
}));
|
||
};
|
||
|
||
// 添加标签
|
||
const addTag = () => {
|
||
if (currentTag.trim() && !formData.tags.includes(currentTag.trim())) {
|
||
if (formData.tags.length >= 5) {
|
||
toast({
|
||
title: '标签数量已达上限',
|
||
description: '最多只能添加5个标签',
|
||
status: 'warning',
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
tags: [...prev.tags, currentTag.trim()],
|
||
}));
|
||
setCurrentTag('');
|
||
}
|
||
};
|
||
|
||
// 移除标签
|
||
const removeTag = (tag) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
tags: prev.tags.filter((t) => t !== tag),
|
||
}));
|
||
};
|
||
|
||
// 提交帖子
|
||
const handleSubmit = async () => {
|
||
if (!validateForm()) return;
|
||
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
const postData = {
|
||
...formData,
|
||
author_id: user?.id || 'anonymous',
|
||
author_name: user?.name || '匿名用户',
|
||
author_avatar: user?.avatar || '',
|
||
};
|
||
|
||
const newPost = await createPost(postData);
|
||
|
||
toast({
|
||
title: '发布成功',
|
||
description: '帖子已成功发布到论坛',
|
||
status: 'success',
|
||
duration: 3000,
|
||
});
|
||
|
||
// 重置表单
|
||
setFormData({
|
||
title: '',
|
||
content: '',
|
||
images: [],
|
||
tags: [],
|
||
category: '',
|
||
});
|
||
|
||
onClose();
|
||
|
||
// 通知父组件刷新
|
||
if (onPostCreated) {
|
||
onPostCreated(newPost);
|
||
}
|
||
} catch (error) {
|
||
console.error('发布帖子失败:', error);
|
||
toast({
|
||
title: '发布失败',
|
||
description: error.message || '发布帖子时出错,请稍后重试',
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||
<ModalOverlay bg="blackAlpha.800" />
|
||
<ModalContent
|
||
bg={forumColors.background.elevated}
|
||
borderColor={forumColors.border.gold}
|
||
borderWidth="1px"
|
||
maxH="90vh"
|
||
>
|
||
<ModalHeader
|
||
color={forumColors.text.primary}
|
||
borderBottomWidth="1px"
|
||
borderBottomColor={forumColors.border.default}
|
||
>
|
||
<Text
|
||
bgGradient={forumColors.text.goldGradient}
|
||
bgClip="text"
|
||
fontWeight="bold"
|
||
fontSize="xl"
|
||
>
|
||
发布新帖
|
||
</Text>
|
||
</ModalHeader>
|
||
<ModalCloseButton color={forumColors.text.secondary} />
|
||
|
||
<ModalBody py="6" overflowY="auto">
|
||
<VStack spacing="5" align="stretch">
|
||
{/* 标题输入 */}
|
||
<FormControl isInvalid={errors.title}>
|
||
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||
标题
|
||
</FormLabel>
|
||
<Input
|
||
placeholder="给你的帖子起个吸引人的标题..."
|
||
value={formData.title}
|
||
onChange={(e) =>
|
||
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
||
}
|
||
bg={forumColors.background.secondary}
|
||
border="1px solid"
|
||
borderColor={forumColors.border.default}
|
||
color={forumColors.text.primary}
|
||
_placeholder={{ color: forumColors.text.tertiary }}
|
||
_hover={{ borderColor: forumColors.border.light }}
|
||
_focus={{
|
||
borderColor: forumColors.border.gold,
|
||
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||
}}
|
||
/>
|
||
<FormErrorMessage>{errors.title}</FormErrorMessage>
|
||
</FormControl>
|
||
|
||
{/* 内容输入 */}
|
||
<FormControl isInvalid={errors.content}>
|
||
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||
内容
|
||
</FormLabel>
|
||
<Textarea
|
||
placeholder="分享你的投资见解、市场观点或交易心得..."
|
||
value={formData.content}
|
||
onChange={(e) =>
|
||
setFormData((prev) => ({ ...prev, content: e.target.value }))
|
||
}
|
||
minH="200px"
|
||
bg={forumColors.background.secondary}
|
||
border="1px solid"
|
||
borderColor={forumColors.border.default}
|
||
color={forumColors.text.primary}
|
||
_placeholder={{ color: forumColors.text.tertiary }}
|
||
_hover={{ borderColor: forumColors.border.light }}
|
||
_focus={{
|
||
borderColor: forumColors.border.gold,
|
||
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||
}}
|
||
resize="vertical"
|
||
/>
|
||
<FormErrorMessage>{errors.content}</FormErrorMessage>
|
||
<Text
|
||
fontSize="xs"
|
||
color={forumColors.text.muted}
|
||
mt="2"
|
||
textAlign="right"
|
||
>
|
||
{formData.content.length} / 5000
|
||
</Text>
|
||
</FormControl>
|
||
|
||
{/* 图片上传 */}
|
||
<Box>
|
||
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||
图片(最多9张)
|
||
</FormLabel>
|
||
|
||
<HStack spacing="3" flexWrap="wrap">
|
||
{formData.images.map((img, index) => (
|
||
<Box key={index} position="relative" w="100px" h="100px">
|
||
<Image
|
||
src={img}
|
||
alt={`预览 ${index + 1}`}
|
||
w="100%"
|
||
h="100%"
|
||
objectFit="cover"
|
||
borderRadius="md"
|
||
border="1px solid"
|
||
borderColor={forumColors.border.default}
|
||
/>
|
||
<IconButton
|
||
icon={<X size={14} />}
|
||
size="xs"
|
||
position="absolute"
|
||
top="-2"
|
||
right="-2"
|
||
borderRadius="full"
|
||
bg={forumColors.background.main}
|
||
color={forumColors.text.primary}
|
||
border="1px solid"
|
||
borderColor={forumColors.border.gold}
|
||
onClick={() => removeImage(index)}
|
||
_hover={{ bg: forumColors.background.hover }}
|
||
/>
|
||
</Box>
|
||
))}
|
||
|
||
{formData.images.length < 9 && (
|
||
<Box
|
||
as="label"
|
||
w="100px"
|
||
h="100px"
|
||
display="flex"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
bg={forumColors.background.secondary}
|
||
border="2px dashed"
|
||
borderColor={forumColors.border.default}
|
||
borderRadius="md"
|
||
cursor="pointer"
|
||
_hover={{ borderColor: forumColors.border.gold }}
|
||
>
|
||
<Input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
display="none"
|
||
onChange={handleImageUpload}
|
||
/>
|
||
<ImagePlus size={24} color={forumColors.text.tertiary} />
|
||
</Box>
|
||
)}
|
||
</HStack>
|
||
</Box>
|
||
|
||
{/* 标签输入 */}
|
||
<Box>
|
||
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||
标签(最多5个)
|
||
</FormLabel>
|
||
|
||
<HStack mb="3">
|
||
<Input
|
||
placeholder="输入标签后按回车"
|
||
value={currentTag}
|
||
onChange={(e) => setCurrentTag(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
addTag();
|
||
}
|
||
}}
|
||
bg={forumColors.background.secondary}
|
||
border="1px solid"
|
||
borderColor={forumColors.border.default}
|
||
color={forumColors.text.primary}
|
||
_placeholder={{ color: forumColors.text.tertiary }}
|
||
_focus={{
|
||
borderColor: forumColors.border.gold,
|
||
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||
}}
|
||
/>
|
||
<IconButton
|
||
icon={<Hash size={18} />}
|
||
onClick={addTag}
|
||
bg={forumColors.gradients.goldPrimary}
|
||
color={forumColors.background.main}
|
||
_hover={{ opacity: 0.9 }}
|
||
/>
|
||
</HStack>
|
||
|
||
<HStack spacing="2" flexWrap="wrap">
|
||
{formData.tags.map((tag) => (
|
||
<Tag
|
||
key={tag}
|
||
size="md"
|
||
bg={forumColors.gradients.goldSubtle}
|
||
color={forumColors.primary[500]}
|
||
border="1px solid"
|
||
borderColor={forumColors.border.gold}
|
||
borderRadius="full"
|
||
>
|
||
<TagLabel>#{tag}</TagLabel>
|
||
<TagCloseButton onClick={() => removeTag(tag)} />
|
||
</Tag>
|
||
))}
|
||
</HStack>
|
||
</Box>
|
||
</VStack>
|
||
</ModalBody>
|
||
|
||
<ModalFooter
|
||
borderTopWidth="1px"
|
||
borderTopColor={forumColors.border.default}
|
||
>
|
||
<HStack spacing="3">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onClose}
|
||
color={forumColors.text.secondary}
|
||
_hover={{ bg: forumColors.background.hover }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
bg={forumColors.gradients.goldPrimary}
|
||
color={forumColors.background.main}
|
||
fontWeight="bold"
|
||
onClick={handleSubmit}
|
||
isLoading={isSubmitting}
|
||
loadingText="发布中..."
|
||
_hover={{
|
||
transform: 'translateY(-2px)',
|
||
boxShadow: forumColors.shadows.goldHover,
|
||
}}
|
||
_active={{ transform: 'translateY(0)' }}
|
||
>
|
||
发布帖子
|
||
</Button>
|
||
</HStack>
|
||
</ModalFooter>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default CreatePostModal;
|