个股论坛重做
This commit is contained in:
@@ -340,8 +340,9 @@ def get_channel_admins(channel_id):
|
|||||||
|
|
||||||
with get_db_engine().connect() as conn:
|
with get_db_engine().connect() as conn:
|
||||||
# 1. 先获取全局超级管理员
|
# 1. 先获取全局超级管理员
|
||||||
|
# 注意:users 表可能没有 avatar 字段,使用 NULL 作为默认值
|
||||||
super_admin_sql = text("""
|
super_admin_sql = text("""
|
||||||
SELECT ca.user_id, u.username, u.avatar
|
SELECT ca.user_id, u.username
|
||||||
FROM community_admins ca
|
FROM community_admins ca
|
||||||
LEFT JOIN users u ON ca.user_id = u.id
|
LEFT JOIN users u ON ca.user_id = u.id
|
||||||
WHERE ca.role = 'admin'
|
WHERE ca.role = 'admin'
|
||||||
@@ -351,8 +352,8 @@ def get_channel_admins(channel_id):
|
|||||||
for row in super_admins:
|
for row in super_admins:
|
||||||
admins.append({
|
admins.append({
|
||||||
'userId': str(row.user_id),
|
'userId': str(row.user_id),
|
||||||
'username': row.username,
|
'username': row.username or f'用户{row.user_id}',
|
||||||
'avatar': row.avatar,
|
'avatar': None,
|
||||||
'role': 'super_admin', # 特殊标记
|
'role': 'super_admin', # 特殊标记
|
||||||
'isSuperAdmin': True,
|
'isSuperAdmin': True,
|
||||||
'permissions': {
|
'permissions': {
|
||||||
@@ -370,44 +371,51 @@ def get_channel_admins(channel_id):
|
|||||||
# 2. 获取频道管理员(排除已在超级管理员列表中的用户)
|
# 2. 获取频道管理员(排除已在超级管理员列表中的用户)
|
||||||
super_admin_ids = [str(row.user_id) for row in super_admins]
|
super_admin_ids = [str(row.user_id) for row in super_admins]
|
||||||
|
|
||||||
sql = text("""
|
# 检查 community_channel_admins 表是否存在
|
||||||
SELECT cca.user_id, cca.role, cca.permissions, cca.created_at,
|
try:
|
||||||
u.username, u.avatar
|
sql = text("""
|
||||||
FROM community_channel_admins cca
|
SELECT cca.user_id, cca.role, cca.permissions, cca.created_at,
|
||||||
LEFT JOIN users u ON cca.user_id = u.id
|
u.username
|
||||||
WHERE cca.channel_id = :channel_id
|
FROM community_channel_admins cca
|
||||||
ORDER BY
|
LEFT JOIN users u ON cca.user_id = u.id
|
||||||
CASE cca.role
|
WHERE cca.channel_id = :channel_id
|
||||||
WHEN 'owner' THEN 1
|
ORDER BY
|
||||||
WHEN 'admin' THEN 2
|
CASE cca.role
|
||||||
WHEN 'moderator' THEN 3
|
WHEN 'owner' THEN 1
|
||||||
END
|
WHEN 'admin' THEN 2
|
||||||
""")
|
WHEN 'moderator' THEN 3
|
||||||
result = conn.execute(sql, {'channel_id': channel_id}).fetchall()
|
END
|
||||||
|
""")
|
||||||
|
result = conn.execute(sql, {'channel_id': channel_id}).fetchall()
|
||||||
|
|
||||||
for row in result:
|
for row in result:
|
||||||
# 跳过已在超级管理员列表中的用户
|
# 跳过已在超级管理员列表中的用户
|
||||||
if str(row.user_id) in super_admin_ids:
|
if str(row.user_id) in super_admin_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
permissions = row.permissions
|
permissions = row.permissions
|
||||||
if isinstance(permissions, str):
|
if isinstance(permissions, str):
|
||||||
permissions = json.loads(permissions)
|
permissions = json.loads(permissions)
|
||||||
|
|
||||||
admins.append({
|
admins.append({
|
||||||
'userId': str(row.user_id),
|
'userId': str(row.user_id),
|
||||||
'username': row.username,
|
'username': row.username or f'用户{row.user_id}',
|
||||||
'avatar': row.avatar,
|
'avatar': None,
|
||||||
'role': row.role,
|
'role': row.role,
|
||||||
'isSuperAdmin': False,
|
'isSuperAdmin': False,
|
||||||
'permissions': permissions or {},
|
'permissions': permissions or {},
|
||||||
'createdAt': row.created_at.isoformat() if row.created_at else None
|
'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)
|
return api_response(admins)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Community API] 获取频道管理员列表失败: {e}")
|
print(f"[Community API] 获取频道管理员列表失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
return api_error(f'获取管理员列表失败: {str(e)}', 500)
|
return api_error(f'获取管理员列表失败: {str(e)}', 500)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 创建帖子弹窗
|
* 创建帖子弹窗 - 增强版
|
||||||
|
* 支持图片上传和股票关联
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
@@ -17,18 +18,34 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
HStack,
|
HStack,
|
||||||
|
VStack,
|
||||||
Tag,
|
Tag,
|
||||||
TagLabel,
|
TagLabel,
|
||||||
TagCloseButton,
|
TagCloseButton,
|
||||||
useColorModeValue,
|
|
||||||
useToast,
|
useToast,
|
||||||
Box,
|
Box,
|
||||||
Wrap,
|
Wrap,
|
||||||
WrapItem,
|
WrapItem,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Image,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Flex,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
Progress,
|
||||||
} from '@chakra-ui/react';
|
} 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 { ForumPost } from '../../../types';
|
||||||
import { createForumPost } from '../../../services/communityService';
|
import { createForumPost, uploadImage, searchStocks, StockSearchResult } from '../../../services/communityService';
|
||||||
|
|
||||||
interface CreatePostModalProps {
|
interface CreatePostModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -41,6 +58,20 @@ interface CreatePostModalProps {
|
|||||||
// 预设标签
|
// 预设标签
|
||||||
const PRESET_TAGS = ['分析', '策略', '新闻', '提问', '讨论', '复盘', '干货', '观点'];
|
const PRESET_TAGS = ['分析', '策略', '新闻', '提问', '讨论', '复盘', '干货', '观点'];
|
||||||
|
|
||||||
|
// 上传的图片接口
|
||||||
|
interface UploadedImage {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中的股票
|
||||||
|
interface SelectedStock {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -54,22 +85,35 @@ const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
|||||||
const [customTag, setCustomTag] = useState('');
|
const [customTag, setCustomTag] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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) => {
|
const addTag = (tag: string) => {
|
||||||
if (tag && !tags.includes(tag) && tags.length < 5) {
|
if (tag && !tags.includes(tag) && tags.length < 5) {
|
||||||
setTags([...tags, tag]);
|
setTags([...tags, tag]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 移除标签
|
|
||||||
const removeTag = (tag: string) => {
|
const removeTag = (tag: string) => {
|
||||||
setTags(tags.filter(t => t !== tag));
|
setTags(tags.filter(t => t !== tag));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加自定义标签
|
|
||||||
const handleAddCustomTag = () => {
|
const handleAddCustomTag = () => {
|
||||||
if (customTag.trim()) {
|
if (customTag.trim()) {
|
||||||
addTag(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({
|
toast({
|
||||||
title: '请输入标题',
|
title: '只能上传图片文件',
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.trim()) {
|
// 检查文件大小(10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: '请输入内容',
|
title: '图片大小不能超过 10MB',
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
return;
|
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 {
|
try {
|
||||||
setSubmitting(true);
|
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({
|
const newPost = await createForumPost({
|
||||||
channelId,
|
channelId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: finalContent.trim(),
|
||||||
tags,
|
tags,
|
||||||
|
stockSymbols: selectedStocks.map(s => s.code),
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({ title: '发布成功', status: 'success', duration: 2000 });
|
||||||
title: '发布成功',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
setTitle('');
|
resetForm();
|
||||||
setContent('');
|
|
||||||
setTags([]);
|
|
||||||
|
|
||||||
onPostCreated(newPost);
|
onPostCreated(newPost);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发布失败:', error);
|
console.error('发布失败:', error);
|
||||||
@@ -132,128 +366,400 @@ const CreatePostModal: React.FC<CreatePostModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 关闭时重置
|
// 重置表单
|
||||||
const handleClose = () => {
|
const resetForm = () => {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setCustomTag('');
|
setCustomTag('');
|
||||||
|
setImages([]);
|
||||||
|
setSelectedStocks([]);
|
||||||
|
setStockQuery('');
|
||||||
|
setStockResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} size="xl">
|
<Modal isOpen={isOpen} onClose={handleClose} size="2xl" scrollBehavior="inside">
|
||||||
<ModalOverlay />
|
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(8px)" />
|
||||||
<ModalContent>
|
<ModalContent
|
||||||
<ModalHeader>
|
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} 发布帖子
|
在 #{channelName} 发布帖子
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton color="gray.400" />
|
||||||
|
|
||||||
<ModalBody>
|
<ModalBody py={4} {...getRootProps()}>
|
||||||
{/* 标题 */}
|
<input {...getInputProps()} />
|
||||||
<FormControl mb={4} isRequired>
|
<input
|
||||||
<FormLabel>标题</FormLabel>
|
type="file"
|
||||||
<Input
|
ref={fileInputRef}
|
||||||
value={title}
|
onChange={handleFileChange}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
accept="image/*"
|
||||||
placeholder="输入帖子标题"
|
multiple
|
||||||
maxLength={100}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
<FormHelperText textAlign="right">
|
|
||||||
{title.length}/100
|
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
{/* 拖拽提示 */}
|
||||||
<FormControl mb={4} isRequired>
|
{isDragActive && (
|
||||||
<FormLabel>内容</FormLabel>
|
<Box
|
||||||
<Textarea
|
position="absolute"
|
||||||
value={content}
|
inset={0}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
bg="rgba(139, 92, 246, 0.2)"
|
||||||
placeholder="分享你的观点..."
|
borderRadius="md"
|
||||||
rows={8}
|
display="flex"
|
||||||
maxLength={10000}
|
alignItems="center"
|
||||||
/>
|
justifyContent="center"
|
||||||
<FormHelperText textAlign="right">
|
zIndex={10}
|
||||||
{content.length}/10000
|
border="2px dashed"
|
||||||
</FormHelperText>
|
borderColor="purple.400"
|
||||||
</FormControl>
|
>
|
||||||
|
<VStack spacing={2}>
|
||||||
{/* 标签 */}
|
<Icon as={ImageIcon} boxSize={12} color="purple.400" />
|
||||||
<FormControl mb={4}>
|
<Text color="purple.300" fontWeight="semibold">释放以上传图片</Text>
|
||||||
<FormLabel>标签(最多5个)</FormLabel>
|
</VStack>
|
||||||
|
|
||||||
{/* 已选标签 */}
|
|
||||||
{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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 自定义标签 */}
|
<VStack spacing={4} align="stretch">
|
||||||
{tags.length < 5 && (
|
{/* 标题 */}
|
||||||
<HStack>
|
<FormControl isRequired>
|
||||||
<Input
|
<FormLabel color="gray.300">标题</FormLabel>
|
||||||
size="sm"
|
<Input
|
||||||
value={customTag}
|
value={title}
|
||||||
onChange={(e) => setCustomTag(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="输入自定义标签"
|
placeholder="输入帖子标题"
|
||||||
maxLength={20}
|
maxLength={100}
|
||||||
onKeyPress={(e) => {
|
bg="rgba(255, 255, 255, 0.05)"
|
||||||
if (e.key === 'Enter') {
|
border="1px solid"
|
||||||
e.preventDefault();
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
handleAddCustomTag();
|
color="white"
|
||||||
}
|
_placeholder={{ color: 'gray.500' }}
|
||||||
}}
|
_focus={{ borderColor: 'purple.400', boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)' }}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={handleAddCustomTag}>
|
<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>
|
</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>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
|
||||||
<Button variant="ghost" mr={3} onClick={handleClose}>
|
<Button variant="ghost" color="gray.400" mr={3} onClick={handleClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
colorScheme="blue"
|
colorScheme="purple"
|
||||||
isLoading={submitting}
|
isLoading={submitting}
|
||||||
|
isDisabled={uploadingCount > 0}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
leftIcon={uploadingCount > 0 ? <Spinner size="xs" /> : undefined}
|
||||||
>
|
>
|
||||||
发布
|
{uploadingCount > 0 ? `上传中 (${uploadingCount})` : '发布'}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user