diff --git a/community_api.py b/community_api.py index 4338fee6..6b3336aa 100644 --- a/community_api.py +++ b/community_api.py @@ -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) diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx index a577c460..47399fa8 100644 --- a/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/CreatePostModal.tsx @@ -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 = ({ isOpen, onClose, @@ -54,22 +85,35 @@ const CreatePostModal: React.FC = ({ const [customTag, setCustomTag] = useState(''); const [submitting, setSubmitting] = useState(false); - const toast = useToast(); - const tagBg = useColorModeValue('gray.100', 'gray.700'); + // 图片相关状态 + const [images, setImages] = useState([]); + const [uploadingCount, setUploadingCount] = useState(0); + const fileInputRef = useRef(null); + + // 股票搜索相关状态 + const [stockQuery, setStockQuery] = useState(''); + const [stockResults, setStockResults] = useState([]); + const [searchingStocks, setSearchingStocks] = useState(false); + const [selectedStocks, setSelectedStocks] = useState([]); + const searchTimeoutRef = useRef(null); + + const toast = useToast(); + const textareaRef = useRef(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 = ({ } }; - // 提交帖子 - 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) => { + 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 = `![图片](${url})\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 => `![图片](${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 = ({ } }; - // 关闭时重置 - const handleClose = () => { + // 重置表单 + const resetForm = () => { setTitle(''); setContent(''); setTags([]); setCustomTag(''); + setImages([]); + setSelectedStocks([]); + setStockQuery(''); + setStockResults([]); + }; + + const handleClose = () => { + resetForm(); onClose(); }; return ( - - - - + + + + 在 #{channelName} 发布帖子 - + - - {/* 标题 */} - - 标题 - setTitle(e.target.value)} - placeholder="输入帖子标题" - maxLength={100} - /> - - {title.length}/100 - - + + + - {/* 内容 */} - - 内容 -