个股论坛重做
This commit is contained in:
@@ -340,8 +340,9 @@ def get_channel_admins(channel_id):
|
||||
|
||||
with get_db_engine().connect() as conn:
|
||||
# 1. 先获取全局超级管理员
|
||||
# 注意:users 表可能没有 avatar 字段,使用 NULL 作为默认值
|
||||
super_admin_sql = text("""
|
||||
SELECT ca.user_id, u.username, u.avatar
|
||||
SELECT ca.user_id, u.username
|
||||
FROM community_admins ca
|
||||
LEFT JOIN users u ON ca.user_id = u.id
|
||||
WHERE ca.role = 'admin'
|
||||
@@ -351,8 +352,8 @@ def get_channel_admins(channel_id):
|
||||
for row in super_admins:
|
||||
admins.append({
|
||||
'userId': str(row.user_id),
|
||||
'username': row.username,
|
||||
'avatar': row.avatar,
|
||||
'username': row.username or f'用户{row.user_id}',
|
||||
'avatar': None,
|
||||
'role': 'super_admin', # 特殊标记
|
||||
'isSuperAdmin': True,
|
||||
'permissions': {
|
||||
@@ -370,44 +371,51 @@ def get_channel_admins(channel_id):
|
||||
# 2. 获取频道管理员(排除已在超级管理员列表中的用户)
|
||||
super_admin_ids = [str(row.user_id) for row in super_admins]
|
||||
|
||||
sql = text("""
|
||||
SELECT cca.user_id, cca.role, cca.permissions, cca.created_at,
|
||||
u.username, u.avatar
|
||||
FROM community_channel_admins cca
|
||||
LEFT JOIN users u ON cca.user_id = u.id
|
||||
WHERE cca.channel_id = :channel_id
|
||||
ORDER BY
|
||||
CASE cca.role
|
||||
WHEN 'owner' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'moderator' THEN 3
|
||||
END
|
||||
""")
|
||||
result = conn.execute(sql, {'channel_id': channel_id}).fetchall()
|
||||
# 检查 community_channel_admins 表是否存在
|
||||
try:
|
||||
sql = text("""
|
||||
SELECT cca.user_id, cca.role, cca.permissions, cca.created_at,
|
||||
u.username
|
||||
FROM community_channel_admins cca
|
||||
LEFT JOIN users u ON cca.user_id = u.id
|
||||
WHERE cca.channel_id = :channel_id
|
||||
ORDER BY
|
||||
CASE cca.role
|
||||
WHEN 'owner' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'moderator' THEN 3
|
||||
END
|
||||
""")
|
||||
result = conn.execute(sql, {'channel_id': channel_id}).fetchall()
|
||||
|
||||
for row in result:
|
||||
# 跳过已在超级管理员列表中的用户
|
||||
if str(row.user_id) in super_admin_ids:
|
||||
continue
|
||||
for row in result:
|
||||
# 跳过已在超级管理员列表中的用户
|
||||
if str(row.user_id) in super_admin_ids:
|
||||
continue
|
||||
|
||||
permissions = row.permissions
|
||||
if isinstance(permissions, str):
|
||||
permissions = json.loads(permissions)
|
||||
permissions = row.permissions
|
||||
if isinstance(permissions, str):
|
||||
permissions = json.loads(permissions)
|
||||
|
||||
admins.append({
|
||||
'userId': str(row.user_id),
|
||||
'username': row.username,
|
||||
'avatar': row.avatar,
|
||||
'role': row.role,
|
||||
'isSuperAdmin': False,
|
||||
'permissions': permissions or {},
|
||||
'createdAt': row.created_at.isoformat() if row.created_at else None
|
||||
})
|
||||
admins.append({
|
||||
'userId': str(row.user_id),
|
||||
'username': row.username or f'用户{row.user_id}',
|
||||
'avatar': None,
|
||||
'role': row.role,
|
||||
'isSuperAdmin': False,
|
||||
'permissions': permissions or {},
|
||||
'createdAt': row.created_at.isoformat() if row.created_at else None
|
||||
})
|
||||
except Exception as table_err:
|
||||
# 表不存在时忽略,只返回超级管理员
|
||||
print(f"[Community API] 频道管理员表查询失败(可能表不存在): {table_err}")
|
||||
|
||||
return api_response(admins)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Community API] 获取频道管理员列表失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return api_error(f'获取管理员列表失败: {str(e)}', 500)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 创建帖子弹窗
|
||||
* 创建帖子弹窗 - 增强版
|
||||
* 支持图片上传和股票关联
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -17,18 +18,34 @@ import {
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
HStack,
|
||||
VStack,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagCloseButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Box,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Image,
|
||||
Spinner,
|
||||
Text,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
List,
|
||||
ListItem,
|
||||
Flex,
|
||||
Badge,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Progress,
|
||||
} from '@chakra-ui/react';
|
||||
import { Image as ImageIcon, X, Search, TrendingUp, Plus, Trash2 } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { ForumPost } from '../../../types';
|
||||
import { createForumPost } from '../../../services/communityService';
|
||||
import { createForumPost, uploadImage, searchStocks, StockSearchResult } from '../../../services/communityService';
|
||||
|
||||
interface CreatePostModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -41,6 +58,20 @@ interface CreatePostModalProps {
|
||||
// 预设标签
|
||||
const PRESET_TAGS = ['分析', '策略', '新闻', '提问', '讨论', '复盘', '干货', '观点'];
|
||||
|
||||
// 上传的图片接口
|
||||
interface UploadedImage {
|
||||
url: string;
|
||||
filename: string;
|
||||
uploading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 选中的股票
|
||||
interface SelectedStock {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -54,22 +85,35 @@ const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
||||
const [customTag, setCustomTag] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
const tagBg = useColorModeValue('gray.100', 'gray.700');
|
||||
// 图片相关状态
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 股票搜索相关状态
|
||||
const [stockQuery, setStockQuery] = useState('');
|
||||
const [stockResults, setStockResults] = useState<StockSearchResult[]>([]);
|
||||
const [searchingStocks, setSearchingStocks] = useState(false);
|
||||
const [selectedStocks, setSelectedStocks] = useState<SelectedStock[]>([]);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// ============================================================
|
||||
// 标签相关
|
||||
// ============================================================
|
||||
|
||||
// 添加标签
|
||||
const addTag = (tag: string) => {
|
||||
if (tag && !tags.includes(tag) && tags.length < 5) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除标签
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
// 添加自定义标签
|
||||
const handleAddCustomTag = () => {
|
||||
if (customTag.trim()) {
|
||||
addTag(customTag.trim());
|
||||
@@ -77,47 +121,237 @@ const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 提交帖子
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
// ============================================================
|
||||
// 图片上传相关
|
||||
// ============================================================
|
||||
|
||||
const handleUploadImage = useCallback(async (file: File) => {
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: '请输入标题',
|
||||
title: '只能上传图片文件',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// 检查文件大小(10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: '请输入内容',
|
||||
title: '图片大小不能超过 10MB',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加临时占位
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
setImages(prev => [...prev, { url: '', filename: tempId, uploading: true }]);
|
||||
setUploadingCount(prev => prev + 1);
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file);
|
||||
|
||||
// 替换临时占位为实际结果
|
||||
setImages(prev =>
|
||||
prev.map(img =>
|
||||
img.filename === tempId
|
||||
? { url: result.url, filename: result.filename, uploading: false }
|
||||
: img
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: '图片上传成功',
|
||||
status: 'success',
|
||||
duration: 1500,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 标记上传失败
|
||||
setImages(prev =>
|
||||
prev.map(img =>
|
||||
img.filename === tempId
|
||||
? { ...img, uploading: false, error: error.message }
|
||||
: img
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: '图片上传失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setUploadingCount(prev => prev - 1);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// 拖拽上传
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
|
||||
},
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
onDrop: (acceptedFiles) => {
|
||||
acceptedFiles.forEach(file => handleUploadImage(file));
|
||||
},
|
||||
noClick: true, // 禁用点击,使用自定义按钮
|
||||
});
|
||||
|
||||
// 点击选择文件
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach(file => handleUploadImage(file));
|
||||
}
|
||||
// 清空 input,允许重复选择同一文件
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 移除图片
|
||||
const removeImage = (filename: string) => {
|
||||
setImages(prev => prev.filter(img => img.filename !== filename));
|
||||
};
|
||||
|
||||
// 插入图片到内容
|
||||
const insertImageToContent = (url: string) => {
|
||||
const imageMarkdown = `\n`;
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent = content.substring(0, start) + imageMarkdown + content.substring(end);
|
||||
setContent(newContent);
|
||||
|
||||
// 移动光标到插入内容之后
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + imageMarkdown.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setContent(prev => prev + imageMarkdown);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 股票搜索相关
|
||||
// ============================================================
|
||||
|
||||
const handleStockSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setStockResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchingStocks(true);
|
||||
try {
|
||||
const results = await searchStocks(query, 10, 'stock');
|
||||
setStockResults(results);
|
||||
} catch (error) {
|
||||
console.error('搜索股票失败:', error);
|
||||
} finally {
|
||||
setSearchingStocks(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 防抖搜索
|
||||
const handleStockQueryChange = (value: string) => {
|
||||
setStockQuery(value);
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
handleStockSearch(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 选择股票
|
||||
const selectStock = (stock: StockSearchResult) => {
|
||||
if (selectedStocks.length >= 5) {
|
||||
toast({
|
||||
title: '最多关联 5 只股票',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStocks.some(s => s.code === stock.stock_code)) {
|
||||
toast({
|
||||
title: '该股票已添加',
|
||||
status: 'info',
|
||||
duration: 1500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedStocks(prev => [...prev, { code: stock.stock_code, name: stock.stock_name }]);
|
||||
setStockQuery('');
|
||||
setStockResults([]);
|
||||
};
|
||||
|
||||
// 移除股票
|
||||
const removeStock = (code: string) => {
|
||||
setSelectedStocks(prev => prev.filter(s => s.code !== code));
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 提交相关
|
||||
// ============================================================
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast({ title: '请输入标题', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
toast({ title: '请输入内容', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadingCount > 0) {
|
||||
toast({ title: '请等待图片上传完成', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
// 构建内容:将图片 URL 转换为 Markdown 格式(如果用户没有手动插入)
|
||||
let finalContent = content;
|
||||
|
||||
// 检查是否有未插入的图片,添加到末尾
|
||||
const uploadedImages = images.filter(img => !img.error && !img.uploading && img.url);
|
||||
const contentImageUrls = uploadedImages.map(img => img.url);
|
||||
const missingImages = contentImageUrls.filter(url => !content.includes(url));
|
||||
|
||||
if (missingImages.length > 0) {
|
||||
finalContent += '\n\n' + missingImages.map(url => ``).join('\n');
|
||||
}
|
||||
|
||||
const newPost = await createForumPost({
|
||||
channelId,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
content: finalContent.trim(),
|
||||
tags,
|
||||
stockSymbols: selectedStocks.map(s => s.code),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
toast({ title: '发布成功', status: 'success', duration: 2000 });
|
||||
|
||||
// 重置表单
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setTags([]);
|
||||
|
||||
resetForm();
|
||||
onPostCreated(newPost);
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
@@ -132,128 +366,400 @@ const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭时重置
|
||||
const handleClose = () => {
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setTags([]);
|
||||
setCustomTag('');
|
||||
setImages([]);
|
||||
setSelectedStocks([]);
|
||||
setStockQuery('');
|
||||
setStockResults([]);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="2xl" scrollBehavior="inside">
|
||||
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(8px)" />
|
||||
<ModalContent
|
||||
bg="rgba(17, 24, 39, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderWidth="1px"
|
||||
maxH="90vh"
|
||||
>
|
||||
<ModalHeader color="white" borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
在 #{channelName} 发布帖子
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalCloseButton color="gray.400" />
|
||||
|
||||
<ModalBody>
|
||||
{/* 标题 */}
|
||||
<FormControl mb={4} isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="输入帖子标题"
|
||||
maxLength={100}
|
||||
/>
|
||||
<FormHelperText textAlign="right">
|
||||
{title.length}/100
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<ModalBody py={4} {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<FormControl mb={4} isRequired>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="分享你的观点..."
|
||||
rows={8}
|
||||
maxLength={10000}
|
||||
/>
|
||||
<FormHelperText textAlign="right">
|
||||
{content.length}/10000
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签 */}
|
||||
<FormControl mb={4}>
|
||||
<FormLabel>标签(最多5个)</FormLabel>
|
||||
|
||||
{/* 已选标签 */}
|
||||
{tags.length > 0 && (
|
||||
<HStack spacing={2} mb={2} flexWrap="wrap">
|
||||
{tags.map(tag => (
|
||||
<Tag
|
||||
key={tag}
|
||||
size="md"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
>
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton onClick={() => removeTag(tag)} />
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 预设标签 */}
|
||||
<Box mb={2}>
|
||||
<Wrap spacing={2}>
|
||||
{PRESET_TAGS.filter(t => !tags.includes(t)).map(tag => (
|
||||
<WrapItem key={tag}>
|
||||
<Tag
|
||||
size="sm"
|
||||
bg={tagBg}
|
||||
cursor="pointer"
|
||||
_hover={{ opacity: 0.8 }}
|
||||
onClick={() => addTag(tag)}
|
||||
>
|
||||
+ {tag}
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragActive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
bg="rgba(139, 92, 246, 0.2)"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex={10}
|
||||
border="2px dashed"
|
||||
borderColor="purple.400"
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={ImageIcon} boxSize={12} color="purple.400" />
|
||||
<Text color="purple.300" fontWeight="semibold">释放以上传图片</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 自定义标签 */}
|
||||
{tags.length < 5 && (
|
||||
<HStack>
|
||||
<Input
|
||||
size="sm"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
placeholder="输入自定义标签"
|
||||
maxLength={20}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustomTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddCustomTag}>
|
||||
添加
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 标题 */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel color="gray.300">标题</FormLabel>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="输入帖子标题"
|
||||
maxLength={100}
|
||||
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)' }}
|
||||
/>
|
||||
<FormHelperText color="gray.500" textAlign="right">
|
||||
{title.length}/100
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 内容 */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel color="gray.300">
|
||||
<HStack justify="space-between">
|
||||
<Text>内容</Text>
|
||||
<Tooltip label="支持 Markdown 格式">
|
||||
<Badge colorScheme="purple" variant="subtle">Markdown</Badge>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="分享你的观点... 支持 Markdown 格式,可以拖拽图片到此处上传"
|
||||
rows={10}
|
||||
maxLength={10000}
|
||||
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)' }}
|
||||
/>
|
||||
<Flex justify="space-between" mt={1}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ImageIcon size={14} />}
|
||||
color="gray.400"
|
||||
_hover={{ color: 'purple.400', bg: 'whiteAlpha.100' }}
|
||||
onClick={handleSelectFile}
|
||||
>
|
||||
插入图片
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="gray.500" fontSize="xs">
|
||||
{content.length}/10000
|
||||
</Text>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
{/* 已上传图片预览 */}
|
||||
{images.length > 0 && (
|
||||
<Box>
|
||||
<Text color="gray.400" fontSize="sm" mb={2}>
|
||||
已上传图片(点击插入到内容)
|
||||
</Text>
|
||||
<Wrap spacing={2}>
|
||||
{images.map((img, index) => (
|
||||
<WrapItem key={img.filename}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="80px"
|
||||
h="80px"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
border="1px solid"
|
||||
borderColor={img.error ? 'red.400' : 'rgba(255, 255, 255, 0.1)'}
|
||||
>
|
||||
{img.uploading ? (
|
||||
<Flex
|
||||
w="full"
|
||||
h="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="rgba(0, 0, 0, 0.5)"
|
||||
>
|
||||
<Spinner size="sm" color="purple.400" />
|
||||
</Flex>
|
||||
) : img.error ? (
|
||||
<Flex
|
||||
w="full"
|
||||
h="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="rgba(239, 68, 68, 0.1)"
|
||||
flexDir="column"
|
||||
>
|
||||
<Icon as={X} color="red.400" boxSize={5} />
|
||||
<Text fontSize="2xs" color="red.400">失败</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={img.filename}
|
||||
w="full"
|
||||
h="full"
|
||||
objectFit="cover"
|
||||
cursor="pointer"
|
||||
onClick={() => insertImageToContent(img.url)}
|
||||
_hover={{ opacity: 0.8 }}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="删除图片"
|
||||
icon={<Trash2 size={12} />}
|
||||
size="xs"
|
||||
position="absolute"
|
||||
top={1}
|
||||
right={1}
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
color="white"
|
||||
_hover={{ bg: 'red.500' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(img.filename);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Divider borderColor="rgba(255, 255, 255, 0.1)" />
|
||||
|
||||
{/* 关联股票 */}
|
||||
<FormControl>
|
||||
<FormLabel color="gray.300">
|
||||
<HStack>
|
||||
<Icon as={TrendingUp} boxSize={4} />
|
||||
<Text>关联股票(最多5只)</Text>
|
||||
</HStack>
|
||||
</FormLabel>
|
||||
|
||||
{/* 已选股票 */}
|
||||
{selectedStocks.length > 0 && (
|
||||
<Wrap spacing={2} mb={3}>
|
||||
{selectedStocks.map(stock => (
|
||||
<WrapItem key={stock.code}>
|
||||
<Tag
|
||||
size="md"
|
||||
colorScheme="orange"
|
||||
borderRadius="full"
|
||||
variant="subtle"
|
||||
>
|
||||
<TagLabel>${stock.code} {stock.name}</TagLabel>
|
||||
<TagCloseButton onClick={() => removeStock(stock.code)} />
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
|
||||
{/* 搜索框 */}
|
||||
{selectedStocks.length < 5 && (
|
||||
<Box position="relative">
|
||||
<InputGroup size="sm">
|
||||
<InputLeftElement>
|
||||
{searchingStocks ? (
|
||||
<Spinner size="xs" color="purple.400" />
|
||||
) : (
|
||||
<Search size={14} color="gray" />
|
||||
)}
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
value={stockQuery}
|
||||
onChange={(e) => handleStockQueryChange(e.target.value)}
|
||||
placeholder="输入股票代码或名称搜索..."
|
||||
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' }}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
{stockResults.length > 0 && (
|
||||
<List
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
mt={1}
|
||||
bg="rgba(17, 24, 39, 0.98)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius="md"
|
||||
maxH="200px"
|
||||
overflowY="auto"
|
||||
zIndex={20}
|
||||
boxShadow="0 10px 40px rgba(0, 0, 0, 0.5)"
|
||||
>
|
||||
{stockResults.map(stock => (
|
||||
<ListItem
|
||||
key={stock.stock_code}
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
onClick={() => selectStock(stock)}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Text color="orange.300" fontWeight="semibold" fontSize="sm">
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
<Text color="white" fontSize="sm">
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme={stock.isIndex ? 'blue' : 'gray'}
|
||||
variant="subtle"
|
||||
>
|
||||
{stock.security_type || (stock.isIndex ? '指数' : 'A股')}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Divider borderColor="rgba(255, 255, 255, 0.1)" />
|
||||
|
||||
{/* 标签 */}
|
||||
<FormControl>
|
||||
<FormLabel color="gray.300">标签(最多5个)</FormLabel>
|
||||
|
||||
{/* 已选标签 */}
|
||||
{tags.length > 0 && (
|
||||
<Wrap spacing={2} mb={2}>
|
||||
{tags.map(tag => (
|
||||
<WrapItem key={tag}>
|
||||
<Tag size="md" colorScheme="blue" borderRadius="full">
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton onClick={() => removeTag(tag)} />
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
|
||||
{/* 预设标签 */}
|
||||
<Box mb={2}>
|
||||
<Wrap spacing={2}>
|
||||
{PRESET_TAGS.filter(t => !tags.includes(t)).map(tag => (
|
||||
<WrapItem key={tag}>
|
||||
<Tag
|
||||
size="sm"
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.400"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.200', color: 'white' }}
|
||||
onClick={() => addTag(tag)}
|
||||
>
|
||||
<Plus size={12} style={{ marginRight: 4 }} />
|
||||
{tag}
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
|
||||
{/* 自定义标签 */}
|
||||
{tags.length < 5 && (
|
||||
<HStack>
|
||||
<Input
|
||||
size="sm"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
placeholder="输入自定义标签"
|
||||
maxLength={20}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
color="white"
|
||||
_placeholder={{ color: 'gray.500' }}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCustomTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="ghost"
|
||||
onClick={handleAddCustomTag}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={handleClose}>
|
||||
<ModalFooter borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||
<Button variant="ghost" color="gray.400" mr={3} onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
colorScheme="purple"
|
||||
isLoading={submitting}
|
||||
isDisabled={uploadingCount > 0}
|
||||
onClick={handleSubmit}
|
||||
leftIcon={uploadingCount > 0 ? <Spinner size="xs" /> : undefined}
|
||||
>
|
||||
发布
|
||||
{uploadingCount > 0 ? `上传中 (${uploadingCount})` : '发布'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
Reference in New Issue
Block a user