From 7c65b1e066db67761fcfda88d5bffc073f261b79 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Tue, 6 Jan 2026 11:08:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AA=E8=82=A1=E8=AE=BA=E5=9D=9B=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- community_api.py | 104 ++++++ .../components/ChannelSidebar/index.tsx | 19 +- .../MessageArea/ForumChannel/PostCard.tsx | 221 ++++++------ .../MessageArea/ForumChannel/index.tsx | 216 +++++++++--- .../components/RightPanel/ConceptInfo.tsx | 315 +++++++++++------- .../components/RightPanel/MemberList.tsx | 163 +++++---- .../components/RightPanel/ThreadList.tsx | 125 ++++--- .../components/RightPanel/index.tsx | 163 +++++++-- .../services/communityService.ts | 27 ++ 9 files changed, 945 insertions(+), 408 deletions(-) diff --git a/community_api.py b/community_api.py index 752c2c5e..da5641e1 100644 --- a/community_api.py +++ b/community_api.py @@ -207,6 +207,110 @@ def get_channel(channel_id): return api_error(f'获取频道失败: {str(e)}', 500) +@community_bp.route('/channels', methods=['POST']) +@login_required +def create_channel(): + """ + 创建新频道 + 请求体: { name, type, topic?, categoryId } + """ + try: + user = g.current_user + data = request.get_json() + + name = data.get('name', '').strip() + channel_type = data.get('type', 'text') + topic = data.get('topic', '').strip() + category_id = data.get('categoryId') + + if not name: + return api_error('频道名称不能为空') + + if len(name) > 50: + return api_error('频道名称不能超过50个字符') + + if channel_type not in ['text', 'forum', 'voice', 'announcement']: + return api_error('无效的频道类型') + + channel_id = f"ch_{generate_id()}" + now = datetime.utcnow() + + with get_db_engine().connect() as conn: + # 如果没有指定分类,使用默认分类(自由讨论) + if not category_id: + default_cat_sql = text(""" + SELECT id FROM community_categories + WHERE name = '自由讨论' OR name LIKE '%讨论%' + ORDER BY position + LIMIT 1 + """) + default_cat = conn.execute(default_cat_sql).fetchone() + if default_cat: + category_id = default_cat.id + else: + # 如果没有找到,使用第一个分类 + first_cat_sql = text("SELECT id FROM community_categories ORDER BY position LIMIT 1") + first_cat = conn.execute(first_cat_sql).fetchone() + if first_cat: + category_id = first_cat.id + else: + return api_error('没有可用的分类,请先创建分类') + + # 获取当前分类下最大的 position + max_pos_sql = text(""" + SELECT COALESCE(MAX(position), 0) as max_pos + FROM community_channels + WHERE category_id = :category_id + """) + max_pos_result = conn.execute(max_pos_sql, {'category_id': category_id}).fetchone() + next_position = (max_pos_result.max_pos or 0) + 1 + + # 插入新频道 + insert_sql = text(""" + INSERT INTO community_channels + (id, category_id, name, type, topic, position, slow_mode, is_readonly, + is_visible, is_system, subscriber_count, message_count, created_at, updated_at) + VALUES + (:id, :category_id, :name, :type, :topic, :position, 0, 0, + 1, 0, 0, 0, :now, :now) + """) + conn.execute(insert_sql, { + 'id': channel_id, + 'category_id': category_id, + 'name': name, + 'type': channel_type, + 'topic': topic, + 'position': next_position, + 'now': now, + }) + conn.commit() + + # 返回创建的频道信息 + response_data = { + 'id': channel_id, + 'categoryId': category_id, + 'name': name, + 'type': channel_type, + 'topic': topic, + 'position': next_position, + 'slowMode': 0, + 'isReadonly': False, + 'isSystem': False, + 'subscriberCount': 0, + 'messageCount': 0, + 'createdAt': now.isoformat(), + } + + print(f"[Community API] 创建频道成功: {channel_id} - {name}") + return api_response(response_data) + + except Exception as e: + print(f"[Community API] 创建频道失败: {e}") + import traceback + traceback.print_exc() + return api_error(f'创建频道失败: {str(e)}', 500) + + @community_bp.route('/channels//subscribe', methods=['POST']) @login_required def subscribe_channel(channel_id): diff --git a/src/views/StockCommunity/components/ChannelSidebar/index.tsx b/src/views/StockCommunity/components/ChannelSidebar/index.tsx index 5b7c65a6..4bc07a62 100644 --- a/src/views/StockCommunity/components/ChannelSidebar/index.tsx +++ b/src/views/StockCommunity/components/ChannelSidebar/index.tsx @@ -52,7 +52,7 @@ import { } from 'lucide-react'; import { Channel, ChannelCategory, ChannelType } from '../../types'; -import { getChannels } from '../../services/communityService'; +import { getChannels, createChannel } from '../../services/communityService'; import { GLASS_BLUR, GLASS_BG, GLASS_BORDER } from '@/constants/glassConfig'; import { useAuth } from '@/contexts/AuthContext'; @@ -171,20 +171,31 @@ const ChannelSidebar: React.FC = ({ setCreating(true); try { - // TODO: 调用创建频道 API + // 调用创建频道 API + const newChannel = await createChannel({ + name: newChannelName.trim(), + type: newChannelType, + topic: newChannelTopic.trim() || undefined, + }); + toast({ title: '频道创建成功', + description: `已创建频道 #${newChannel.name}`, status: 'success', duration: 2000, }); onCreateClose(); + // 刷新频道列表 const data = await getChannels(); setCategories(data); - } catch (error) { + + // 自动选中新创建的频道 + onChannelSelect(newChannel); + } catch (error: any) { toast({ title: '创建失败', - description: String(error), + description: error.message || String(error), status: 'error', duration: 3000, }); diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx index dee6d3ec..2994b195 100644 --- a/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/PostCard.tsx @@ -1,5 +1,5 @@ /** - * 帖子卡片组件 + * 帖子卡片组件 - HeroUI 深色风格 */ import React from 'react'; import { @@ -10,11 +10,11 @@ import { HStack, Tag, Icon, - useColorModeValue, } from '@chakra-ui/react'; -import { MdChat, MdVisibility, MdThumbUp, MdPushPin, MdLock } from 'react-icons/md'; +import { MessageCircle, Eye, ThumbsUp, Pin, Lock } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; +import { motion } from 'framer-motion'; import { ForumPost } from '../../../types'; @@ -24,12 +24,6 @@ interface PostCardProps { } const PostCard: React.FC = ({ post, onClick }) => { - const bgColor = useColorModeValue('white', 'gray.800'); - const hoverBg = useColorModeValue('gray.50', 'gray.700'); - const borderColor = useColorModeValue('gray.200', 'gray.700'); - const textColor = useColorModeValue('gray.800', 'gray.100'); - const mutedColor = useColorModeValue('gray.500', 'gray.400'); - // 格式化时间 const formatTime = (dateStr: string) => { return formatDistanceToNow(new Date(dateStr), { @@ -39,107 +33,130 @@ const PostCard: React.FC = ({ post, onClick }) => { }; return ( - - - {/* 作者头像 */} - + + + + {/* 作者头像 */} + - {/* 帖子内容 */} - - {/* 标题和标记 */} - - {post.isPinned && ( - - )} - {post.isLocked && ( - - )} + {/* 帖子内容 */} + + {/* 标题和标记 */} + + {post.isPinned && ( + + )} + {post.isLocked && ( + + )} + + {post.title} + + + + {/* 内容预览 */} - {post.title} + {post.content.replace(/<[^>]*>/g, '').slice(0, 150)} - - {/* 内容预览 */} - - {post.content.replace(/<[^>]*>/g, '').slice(0, 150)} - - - {/* 标签 */} - {post.tags && post.tags.length > 0 && ( - - {post.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} - {post.tags.length > 3 && ( - - +{post.tags.length - 3} - - )} - - )} - - {/* 底部信息 */} - - {/* 作者和时间 */} - - {post.authorName} - · - {formatTime(post.createdAt)} - {post.lastReplyAt && post.lastReplyAt !== post.createdAt && ( - <> - · - 最后回复 {formatTime(post.lastReplyAt)} - - )} - - - {/* 统计数据 */} - - - - {post.replyCount} + {/* 标签 */} + {post.tags && post.tags.length > 0 && ( + + {post.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} + {post.tags.length > 3 && ( + + +{post.tags.length - 3} + + )} - - - {post.viewCount} + )} + + {/* 底部信息 */} + + {/* 作者和时间 */} + + {post.authorName} + · + {formatTime(post.createdAt)} + {post.lastReplyAt && post.lastReplyAt !== post.createdAt && ( + <> + · + 最后回复 {formatTime(post.lastReplyAt)} + + )} - - - {post.likeCount} + + {/* 统计数据 */} + + + + + {post.replyCount} + + + + + {post.viewCount} + + + + {post.likeCount} + - - - - - + + + + + ); }; diff --git a/src/views/StockCommunity/components/MessageArea/ForumChannel/index.tsx b/src/views/StockCommunity/components/MessageArea/ForumChannel/index.tsx index 2533bd4d..1e126433 100644 --- a/src/views/StockCommunity/components/MessageArea/ForumChannel/index.tsx +++ b/src/views/StockCommunity/components/MessageArea/ForumChannel/index.tsx @@ -1,5 +1,5 @@ /** - * Forum 帖子频道组件 + * Forum 帖子频道组件 - HeroUI 深色风格 * 帖子列表 + 发帖入口 */ import React, { useState, useEffect, useCallback } from 'react'; @@ -12,18 +12,21 @@ import { IconButton, HStack, Select, - useColorModeValue, Spinner, useDisclosure, + VStack, + Tooltip, + Badge, } from '@chakra-ui/react'; -import { MdAdd, MdSort, MdSearch, MdPushPin } from 'react-icons/md'; +import { motion } from 'framer-motion'; +import { Plus, Filter, Search, Pin, FileText, Hash } from 'lucide-react'; import { Channel, ForumPost } from '../../../types'; import { getForumPosts } from '../../../services/communityService'; -import ChannelHeader from '../shared/ChannelHeader'; import PostCard from './PostCard'; import PostDetail from './PostDetail'; import CreatePostModal from './CreatePostModal'; +import { GLASS_BLUR } from '@/constants/glassConfig'; interface ForumChannelProps { channel: Channel; @@ -47,10 +50,6 @@ const ForumChannel: React.FC = ({ const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure(); - // 颜色 - const bgColor = useColorModeValue('white', 'gray.800'); - const headerBg = useColorModeValue('gray.50', 'gray.900'); - // 加载帖子 const loadPosts = useCallback(async (pageNum: number = 1, append: boolean = false) => { try { @@ -69,7 +68,6 @@ const ForumChannel: React.FC = ({ if (append) { setPosts(prev => [...prev, ...response.items.filter(p => !p.isPinned)]); } else { - // 分离置顶帖子 setPinnedPosts(response.items.filter(p => p.isPinned)); setPosts(response.items.filter(p => !p.isPinned)); } @@ -89,11 +87,6 @@ const ForumChannel: React.FC = ({ loadPosts(1); }, [loadPosts]); - // 排序变化时重新加载 - useEffect(() => { - loadPosts(1); - }, [sortBy, loadPosts]); - // 加载更多 const handleLoadMore = () => { if (!loadingMore && hasMore) { @@ -119,20 +112,55 @@ const ForumChannel: React.FC = ({ return ( - {/* 频道头部 */} - + {/* 频道头部 - HeroUI 深色风格 */} + + + + + + + + {channel.name} + + {channel.isHot && ( + + 热门 + + )} + + {channel.topic && ( + + {channel.topic} + + )} + + + + } + icon={} variant="ghost" size="sm" + color="gray.400" + _hover={{ bg: 'whiteAlpha.100', color: 'white' }} /> - - } - /> + + + {/* 工具栏 */} = ({ justify="space-between" px={4} py={3} - bg={headerBg} - borderBottomWidth="1px" + borderBottom="1px solid" + borderColor="rgba(255, 255, 255, 0.08)" + bg="rgba(17, 24, 39, 0.4)" > - + + + - + setSearchTerm(e.target.value)} - borderRadius="md" + borderRadius="lg" + 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)', + }} /> @@ -159,13 +196,13 @@ const MemberList: React.FC = ({ channelId }) => { py={2} fontSize="xs" fontWeight="bold" - color={mutedColor} + color="green.400" textTransform="uppercase" > 在线 — {onlineMembers.length} - {onlineMembers.map(renderMember)} + {onlineMembers.map((member, index) => renderMember(member, index))} )} @@ -178,20 +215,20 @@ const MemberList: React.FC = ({ channelId }) => { py={2} fontSize="xs" fontWeight="bold" - color={mutedColor} + color="gray.500" textTransform="uppercase" > 离线 — {offlineMembers.length} - {offlineMembers.map(renderMember)} + {offlineMembers.map((member, index) => renderMember(member, onlineMembers.length + index))} )} {/* 空状态 */} {filteredMembers.length === 0 && ( - + {searchTerm ? '未找到匹配的成员' : '暂无成员'} )} diff --git a/src/views/StockCommunity/components/RightPanel/ThreadList.tsx b/src/views/StockCommunity/components/RightPanel/ThreadList.tsx index d15a54be..40ae5856 100644 --- a/src/views/StockCommunity/components/RightPanel/ThreadList.tsx +++ b/src/views/StockCommunity/components/RightPanel/ThreadList.tsx @@ -1,5 +1,5 @@ /** - * 讨论串列表组件 + * 讨论串列表组件 - HeroUI 深色风格 */ import React, { useState, useEffect } from 'react'; import { @@ -7,14 +7,13 @@ import { VStack, HStack, Text, - Badge, Skeleton, - useColorModeValue, Icon, } from '@chakra-ui/react'; -import { MdForum, MdPushPin } from 'react-icons/md'; +import { MessageSquare, Pin } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; +import { motion } from 'framer-motion'; import { Thread } from '../../types'; @@ -75,10 +74,6 @@ const ThreadList: React.FC = ({ channelId }) => { const [threads, setThreads] = useState([]); const [loading, setLoading] = useState(true); - const hoverBg = useColorModeValue('gray.100', 'gray.700'); - const textColor = useColorModeValue('gray.800', 'gray.100'); - const mutedColor = useColorModeValue('gray.500', 'gray.400'); - // 加载讨论串 useEffect(() => { const loadThreads = async () => { @@ -101,37 +96,54 @@ const ThreadList: React.FC = ({ channelId }) => { }; // 渲染讨论串项 - const renderThread = (thread: Thread) => ( - ( + - - {thread.isPinned && ( - - )} - - - {thread.name} - - + + + {thread.isPinned && ( + + )} + + + {thread.name} + + - - {thread.messageCount} 条消息 - · - {formatTime(thread.lastMessageAt || thread.createdAt)} - - + + + + {thread.messageCount} + + 条消息 + + · + {formatTime(thread.lastMessageAt || thread.createdAt)} + + + ); if (loading) { @@ -140,8 +152,18 @@ const ThreadList: React.FC = ({ channelId }) => { {[1, 2, 3].map(i => ( - - + + ))} @@ -163,13 +185,13 @@ const ThreadList: React.FC = ({ channelId }) => { py={2} fontSize="xs" fontWeight="bold" - color={mutedColor} + color="purple.400" textTransform="uppercase" > 置顶 - {pinnedThreads.map(renderThread)} + {pinnedThreads.map((thread, index) => renderThread(thread, index))} )} @@ -182,23 +204,34 @@ const ThreadList: React.FC = ({ channelId }) => { py={2} fontSize="xs" fontWeight="bold" - color={mutedColor} + color="gray.500" textTransform="uppercase" > 活跃讨论串 - {activeThreads.map(renderThread)} + {activeThreads.map((thread, index) => + renderThread(thread, pinnedThreads.length + index) + )} )} {/* 空状态 */} {threads.length === 0 && ( - - - 暂无讨论串 - + + + + + 暂无讨论串 + 在消息上点击"创建讨论串"开始 diff --git a/src/views/StockCommunity/components/RightPanel/index.tsx b/src/views/StockCommunity/components/RightPanel/index.tsx index 26138461..431a3726 100644 --- a/src/views/StockCommunity/components/RightPanel/index.tsx +++ b/src/views/StockCommunity/components/RightPanel/index.tsx @@ -1,5 +1,5 @@ /** - * 右侧面板组件 + * 右侧面板组件 - HeroUI 深色风格 * 显示成员列表 / 概念详情 / 讨论串 */ import React from 'react'; @@ -10,13 +10,18 @@ import { TabPanels, Tab, TabPanel, - useColorModeValue, + Text, + Icon, + VStack, } from '@chakra-ui/react'; +import { Users, Info, MessageSquare } from 'lucide-react'; +import { motion } from 'framer-motion'; import { Channel } from '../../types'; import MemberList from './MemberList'; import ConceptInfo from './ConceptInfo'; import ThreadList from './ThreadList'; +import { GLASS_BLUR } from '@/constants/glassConfig'; interface RightPanelProps { channel: Channel | null; @@ -29,9 +34,6 @@ const RightPanel: React.FC = ({ contentType, onContentTypeChange, }) => { - const bgColor = useColorModeValue('white', 'gray.800'); - const headerBg = useColorModeValue('gray.50', 'gray.900'); - // Tab 索引映射 const tabIndexMap = { members: 0, @@ -46,10 +48,24 @@ const RightPanel: React.FC = ({ if (!channel) { return ( - - - 选择一个频道查看详情 - + + + + + + + 选择一个频道查看详情 + + ); } @@ -58,38 +74,147 @@ const RightPanel: React.FC = ({ const isConceptChannel = !!channel.conceptCode; return ( - + - - 成员 - {isConceptChannel && 概念} - 讨论串 + + + + 成员 + + {isConceptChannel && ( + + + 概念 + + )} + + + 讨论串 + - + {/* 成员列表 */} - + + + {/* 概念详情(仅概念频道) */} {isConceptChannel && ( - + + + )} {/* 讨论串列表 */} - + + + diff --git a/src/views/StockCommunity/services/communityService.ts b/src/views/StockCommunity/services/communityService.ts index 5dd32990..9263ce26 100644 --- a/src/views/StockCommunity/services/communityService.ts +++ b/src/views/StockCommunity/services/communityService.ts @@ -61,6 +61,33 @@ export const unsubscribeChannel = async (channelId: string): Promise => { if (!response.ok) throw new Error('取消订阅失败'); }; +interface CreateChannelData { + name: string; + type: 'text' | 'forum' | 'voice' | 'announcement'; + topic?: string; + categoryId?: string; +} + +/** + * 创建新频道 + */ +export const createChannel = async (data: CreateChannelData): Promise => { + const response = await fetch(`${API_BASE}/api/community/channels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || '创建频道失败'); + } + + const result = await response.json(); + return result.data; +}; + // ============================================================ // 消息相关(即时聊天) // ============================================================