个股论坛重做

This commit is contained in:
2026-01-06 11:08:33 +08:00
parent 03160da91f
commit 7c65b1e066
9 changed files with 945 additions and 408 deletions

View File

@@ -207,6 +207,110 @@ def get_channel(channel_id):
return api_error(f'获取频道失败: {str(e)}', 500) 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/<channel_id>/subscribe', methods=['POST']) @community_bp.route('/channels/<channel_id>/subscribe', methods=['POST'])
@login_required @login_required
def subscribe_channel(channel_id): def subscribe_channel(channel_id):

View File

@@ -52,7 +52,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Channel, ChannelCategory, ChannelType } from '../../types'; 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 { GLASS_BLUR, GLASS_BG, GLASS_BORDER } from '@/constants/glassConfig';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@@ -171,20 +171,31 @@ const ChannelSidebar: React.FC<ChannelSidebarProps> = ({
setCreating(true); setCreating(true);
try { try {
// TODO: 调用创建频道 API // 调用创建频道 API
const newChannel = await createChannel({
name: newChannelName.trim(),
type: newChannelType,
topic: newChannelTopic.trim() || undefined,
});
toast({ toast({
title: '频道创建成功', title: '频道创建成功',
description: `已创建频道 #${newChannel.name}`,
status: 'success', status: 'success',
duration: 2000, duration: 2000,
}); });
onCreateClose(); onCreateClose();
// 刷新频道列表 // 刷新频道列表
const data = await getChannels(); const data = await getChannels();
setCategories(data); setCategories(data);
} catch (error) {
// 自动选中新创建的频道
onChannelSelect(newChannel);
} catch (error: any) {
toast({ toast({
title: '创建失败', title: '创建失败',
description: String(error), description: error.message || String(error),
status: 'error', status: 'error',
duration: 3000, duration: 3000,
}); });

View File

@@ -1,5 +1,5 @@
/** /**
* 帖子卡片组件 * 帖子卡片组件 - HeroUI 深色风格
*/ */
import React from 'react'; import React from 'react';
import { import {
@@ -10,11 +10,11 @@ import {
HStack, HStack,
Tag, Tag,
Icon, Icon,
useColorModeValue,
} from '@chakra-ui/react'; } 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 { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; import { zhCN } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { ForumPost } from '../../../types'; import { ForumPost } from '../../../types';
@@ -24,12 +24,6 @@ interface PostCardProps {
} }
const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => { const PostCard: React.FC<PostCardProps> = ({ 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) => { const formatTime = (dateStr: string) => {
return formatDistanceToNow(new Date(dateStr), { return formatDistanceToNow(new Date(dateStr), {
@@ -39,107 +33,130 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
}; };
return ( return (
<Box <motion.div whileHover={{ scale: 1.005 }} whileTap={{ scale: 0.995 }}>
bg={bgColor} <Box
borderWidth="1px" bg="rgba(255, 255, 255, 0.03)"
borderColor={borderColor} border="1px solid"
borderRadius="lg" borderColor="rgba(255, 255, 255, 0.08)"
p={4} borderRadius="xl"
mb={3} p={4}
cursor="pointer" mb={3}
transition="all 0.2s" cursor="pointer"
_hover={{ bg: hoverBg, transform: 'translateY(-1px)', shadow: 'sm' }} transition="all 0.2s"
onClick={onClick} _hover={{
> bg: 'rgba(255, 255, 255, 0.06)',
<Flex> borderColor: 'rgba(139, 92, 246, 0.3)',
{/* 作者头像 */} boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
<Avatar }}
size="md" onClick={onClick}
name={post.authorName} >
src={post.authorAvatar} <Flex>
mr={3} {/* 作者头像 */}
/> <Avatar
size="md"
name={post.authorName}
src={post.authorAvatar}
mr={3}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))"
/>
{/* 帖子内容 */} {/* 帖子内容 */}
<Box flex={1}> <Box flex={1}>
{/* 标题和标记 */} {/* 标题和标记 */}
<HStack spacing={2} mb={1}> <HStack spacing={2} mb={1}>
{post.isPinned && ( {post.isPinned && (
<Icon as={MdPushPin} color="blue.500" boxSize={4} /> <Icon as={Pin} color="purple.400" boxSize={4} />
)} )}
{post.isLocked && ( {post.isLocked && (
<Icon as={MdLock} color="orange.500" boxSize={4} /> <Icon as={Lock} color="orange.400" boxSize={4} />
)} )}
<Text
fontWeight="bold"
fontSize="md"
color="white"
noOfLines={1}
>
{post.title}
</Text>
</HStack>
{/* 内容预览 */}
<Text <Text
fontWeight="bold" color="gray.400"
fontSize="md" fontSize="sm"
color={textColor} noOfLines={2}
noOfLines={1} mb={2}
> >
{post.title} {post.content.replace(/<[^>]*>/g, '').slice(0, 150)}
</Text> </Text>
</HStack>
{/* 内容预览 */} {/* 标签 */}
<Text {post.tags && post.tags.length > 0 && (
color={mutedColor} <HStack spacing={1} mb={2} flexWrap="wrap">
fontSize="sm" {post.tags.slice(0, 3).map(tag => (
noOfLines={2} <Tag
mb={2} key={tag}
> size="sm"
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)} bg="rgba(59, 130, 246, 0.15)"
</Text> color="blue.300"
border="1px solid"
{/* 标签 */} borderColor="rgba(59, 130, 246, 0.3)"
{post.tags && post.tags.length > 0 && ( >
<HStack spacing={1} mb={2} flexWrap="wrap"> {tag}
{post.tags.slice(0, 3).map(tag => ( </Tag>
<Tag key={tag} size="sm" colorScheme="blue" variant="subtle"> ))}
{tag} {post.tags.length > 3 && (
</Tag> <Text fontSize="xs" color="gray.500">
))} +{post.tags.length - 3}
{post.tags.length > 3 && ( </Text>
<Text fontSize="xs" color={mutedColor}> )}
+{post.tags.length - 3}
</Text>
)}
</HStack>
)}
{/* 底部信息 */}
<Flex align="center" justify="space-between">
{/* 作者和时间 */}
<HStack spacing={2} fontSize="sm" color={mutedColor}>
<Text fontWeight="medium">{post.authorName}</Text>
<Text>·</Text>
<Text>{formatTime(post.createdAt)}</Text>
{post.lastReplyAt && post.lastReplyAt !== post.createdAt && (
<>
<Text>·</Text>
<Text> {formatTime(post.lastReplyAt)}</Text>
</>
)}
</HStack>
{/* 统计数据 */}
<HStack spacing={4} fontSize="sm" color={mutedColor}>
<HStack spacing={1}>
<Icon as={MdChat} boxSize={4} />
<Text>{post.replyCount}</Text>
</HStack> </HStack>
<HStack spacing={1}> )}
<Icon as={MdVisibility} boxSize={4} />
<Text>{post.viewCount}</Text> {/* 底部信息 */}
<Flex align="center" justify="space-between">
{/* 作者和时间 */}
<HStack spacing={2} fontSize="sm" color="gray.500">
<Text fontWeight="medium" color="purple.300">{post.authorName}</Text>
<Text>·</Text>
<Text>{formatTime(post.createdAt)}</Text>
{post.lastReplyAt && post.lastReplyAt !== post.createdAt && (
<>
<Text>·</Text>
<Text> {formatTime(post.lastReplyAt)}</Text>
</>
)}
</HStack> </HStack>
<HStack spacing={1}>
<Icon as={MdThumbUp} boxSize={4} /> {/* 统计数据 */}
<Text>{post.likeCount}</Text> <HStack spacing={4} fontSize="sm" color="gray.500">
<HStack spacing={1}>
<Icon as={MessageCircle} boxSize={4} />
<Text
color="purple.300"
bg="rgba(139, 92, 246, 0.15)"
px={1.5}
borderRadius="sm"
fontSize="xs"
fontWeight="bold"
>
{post.replyCount}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Eye} boxSize={4} />
<Text>{post.viewCount}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={ThumbsUp} boxSize={4} />
<Text>{post.likeCount}</Text>
</HStack>
</HStack> </HStack>
</HStack> </Flex>
</Flex> </Box>
</Box> </Flex>
</Flex> </Box>
</Box> </motion.div>
); );
}; };

View File

@@ -1,5 +1,5 @@
/** /**
* Forum 帖子频道组件 * Forum 帖子频道组件 - HeroUI 深色风格
* 帖子列表 + 发帖入口 * 帖子列表 + 发帖入口
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
@@ -12,18 +12,21 @@ import {
IconButton, IconButton,
HStack, HStack,
Select, Select,
useColorModeValue,
Spinner, Spinner,
useDisclosure, useDisclosure,
VStack,
Tooltip,
Badge,
} from '@chakra-ui/react'; } 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 { Channel, ForumPost } from '../../../types';
import { getForumPosts } from '../../../services/communityService'; import { getForumPosts } from '../../../services/communityService';
import ChannelHeader from '../shared/ChannelHeader';
import PostCard from './PostCard'; import PostCard from './PostCard';
import PostDetail from './PostDetail'; import PostDetail from './PostDetail';
import CreatePostModal from './CreatePostModal'; import CreatePostModal from './CreatePostModal';
import { GLASS_BLUR } from '@/constants/glassConfig';
interface ForumChannelProps { interface ForumChannelProps {
channel: Channel; channel: Channel;
@@ -47,10 +50,6 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure(); 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) => { const loadPosts = useCallback(async (pageNum: number = 1, append: boolean = false) => {
try { try {
@@ -69,7 +68,6 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
if (append) { if (append) {
setPosts(prev => [...prev, ...response.items.filter(p => !p.isPinned)]); setPosts(prev => [...prev, ...response.items.filter(p => !p.isPinned)]);
} else { } else {
// 分离置顶帖子
setPinnedPosts(response.items.filter(p => p.isPinned)); setPinnedPosts(response.items.filter(p => p.isPinned));
setPosts(response.items.filter(p => !p.isPinned)); setPosts(response.items.filter(p => !p.isPinned));
} }
@@ -89,11 +87,6 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
loadPosts(1); loadPosts(1);
}, [loadPosts]); }, [loadPosts]);
// 排序变化时重新加载
useEffect(() => {
loadPosts(1);
}, [sortBy, loadPosts]);
// 加载更多 // 加载更多
const handleLoadMore = () => { const handleLoadMore = () => {
if (!loadingMore && hasMore) { if (!loadingMore && hasMore) {
@@ -119,20 +112,55 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
return ( return (
<Flex direction="column" h="full"> <Flex direction="column" h="full">
{/* 频道头部 */} {/* 频道头部 - HeroUI 深色风格 */}
<ChannelHeader <Box
channel={channel} px={4}
rightActions={ py={3}
<> borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
bg="rgba(17, 24, 39, 0.6)"
backdropFilter={GLASS_BLUR.sm}
>
<Flex justify="space-between" align="center">
<HStack spacing={3}>
<Icon as={FileText} boxSize={5} color="gray.400" />
<Box>
<HStack spacing={2}>
<Text fontWeight="semibold" color="white" fontSize="md">
{channel.name}
</Text>
{channel.isHot && (
<Badge
bg="rgba(251, 146, 60, 0.2)"
color="orange.300"
fontSize="xs"
px={2}
borderRadius="full"
>
</Badge>
)}
</HStack>
{channel.topic && (
<Text fontSize="xs" color="gray.500" mt={0.5}>
{channel.topic}
</Text>
)}
</Box>
</HStack>
<Tooltip label="搜索帖子" placement="bottom">
<IconButton <IconButton
aria-label="搜索" aria-label="搜索"
icon={<Icon as={MdSearch} />} icon={<Search className="w-4 h-4" />}
variant="ghost" variant="ghost"
size="sm" size="sm"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
/> />
</> </Tooltip>
} </Flex>
/> </Box>
{/* 工具栏 */} {/* 工具栏 */}
<Flex <Flex
@@ -140,25 +168,47 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
justify="space-between" justify="space-between"
px={4} px={4}
py={3} py={3}
bg={headerBg} borderBottom="1px solid"
borderBottomWidth="1px" borderColor="rgba(255, 255, 255, 0.08)"
bg="rgba(17, 24, 39, 0.4)"
> >
<Button <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
leftIcon={<MdAdd />} <Button
colorScheme="blue" leftIcon={<Plus className="w-4 h-4" />}
size="sm" size="sm"
onClick={onCreateOpen} bgGradient="linear(to-r, purple.500, blue.500)"
> color="white"
_hover={{
</Button> bgGradient: 'linear(to-r, purple.600, blue.600)',
boxShadow: '0 0 20px rgba(139, 92, 246, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={MdSort} color="gray.500" /> <Icon as={Filter} boxSize={4} color="gray.500" />
<Select <Select
size="sm" size="sm"
w="120px" w="120px"
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)} onChange={(e) => setSortBy(e.target.value as SortOption)}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_focus={{
borderColor: 'purple.400',
boxShadow: '0 0 0 1px var(--chakra-colors-purple-400)',
}}
sx={{
option: {
bg: '#1f2937',
color: 'white',
},
}}
> >
<option value="latest"></option> <option value="latest"></option>
<option value="hot"></option> <option value="hot"></option>
@@ -168,19 +218,42 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
</Flex> </Flex>
{/* 帖子列表 */} {/* 帖子列表 */}
<Box flex={1} overflowY="auto" px={4} py={4}> <Box
flex={1}
overflowY="auto"
px={4}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255, 255, 255, 0.02)',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(139, 92, 246, 0.2)',
borderRadius: '4px',
'&:hover': {
background: 'rgba(139, 92, 246, 0.4)',
},
},
}}
>
{loading ? ( {loading ? (
<Flex justify="center" align="center" h="200px"> <Flex justify="center" align="center" h="200px">
<Spinner size="lg" /> <VStack spacing={3}>
<Spinner size="lg" color="purple.400" thickness="3px" />
<Text color="gray.500" fontSize="sm">...</Text>
</VStack>
</Flex> </Flex>
) : ( ) : (
<> <>
{/* 置顶帖子 */} {/* 置顶帖子 */}
{pinnedPosts.length > 0 && ( {pinnedPosts.length > 0 && (
<Box mb={4}> <Box mb={4}>
<HStack mb={2}> <HStack mb={3}>
<Icon as={MdPushPin} color="blue.500" /> <Icon as={Pin} boxSize={4} color="purple.400" />
<Text fontSize="sm" fontWeight="semibold" color="blue.500"> <Text fontSize="sm" fontWeight="semibold" color="purple.300">
</Text> </Text>
</HStack> </HStack>
@@ -196,26 +269,57 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
{/* 普通帖子 */} {/* 普通帖子 */}
{posts.length === 0 && pinnedPosts.length === 0 ? ( {posts.length === 0 && pinnedPosts.length === 0 ? (
<Flex <motion.div
direction="column" initial={{ opacity: 0, y: 20 }}
align="center" animate={{ opacity: 1, y: 0 }}
justify="center" transition={{ duration: 0.3 }}
h="200px"
color="gray.500"
> >
<Text mb={2}></Text> <Flex
<Button size="sm" colorScheme="blue" onClick={onCreateOpen}> direction="column"
align="center"
</Button> justify="center"
</Flex> py={12}
>
<Box
p={4}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.2))"
borderRadius="full"
mb={4}
>
<Icon as={FileText} boxSize={8} color="purple.400" />
</Box>
<Text color="gray.400" mb={4}></Text>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
bgGradient="linear(to-r, purple.500, blue.500)"
color="white"
leftIcon={<Plus className="w-4 h-4" />}
_hover={{
bgGradient: 'linear(to-r, purple.600, blue.600)',
boxShadow: '0 0 20px rgba(139, 92, 246, 0.4)',
}}
onClick={onCreateOpen}
>
</Button>
</motion.div>
</Flex>
</motion.div>
) : ( ) : (
<> <>
{posts.map(post => ( {posts.map((post, index) => (
<PostCard <motion.div
key={post.id} key={post.id}
post={post} initial={{ opacity: 0, y: 10 }}
onClick={() => setSelectedPost(post)} animate={{ opacity: 1, y: 0 }}
/> transition={{ delay: index * 0.05 }}
>
<PostCard
post={post}
onClick={() => setSelectedPost(post)}
/>
</motion.div>
))} ))}
{/* 加载更多 */} {/* 加载更多 */}
@@ -224,8 +328,10 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
color="gray.400"
isLoading={loadingMore} isLoading={loadingMore}
onClick={handleLoadMore} onClick={handleLoadMore}
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
> >
</Button> </Button>

View File

@@ -1,5 +1,5 @@
/** /**
* 概念详情组件 * 概念详情组件 - HeroUI 深色风格
* 显示概念板块的相关信息和成分股 * 显示概念板块的相关信息和成分股
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
@@ -9,18 +9,17 @@ import {
HStack, HStack,
Text, Text,
Stat, Stat,
StatLabel,
StatNumber, StatNumber,
StatHelpText, StatHelpText,
StatArrow, StatArrow,
SimpleGrid,
Skeleton, Skeleton,
useColorModeValue,
Divider, Divider,
Tag, Tag,
Button, Button,
Icon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { MdTrendingUp, MdTrendingDown } from 'react-icons/md'; import { TrendingUp, ExternalLink } from 'lucide-react';
import { motion } from 'framer-motion';
interface ConceptInfoProps { interface ConceptInfoProps {
conceptCode: string; conceptCode: string;
@@ -48,11 +47,6 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
const [conceptData, setConceptData] = useState<ConceptData | null>(null); const [conceptData, setConceptData] = useState<ConceptData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const bgColor = useColorModeValue('white', 'gray.800');
const cardBg = useColorModeValue('gray.50', 'gray.700');
const textColor = useColorModeValue('gray.800', 'gray.100');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
// 加载概念数据 // 加载概念数据
useEffect(() => { useEffect(() => {
const loadConcept = async () => { const loadConcept = async () => {
@@ -90,9 +84,9 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
return ( return (
<Box p={4}> <Box p={4}>
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
<Skeleton h="60px" /> <Skeleton h="60px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
<Skeleton h="100px" /> <Skeleton h="100px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
<Skeleton h="150px" /> <Skeleton h="150px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
</VStack> </VStack>
</Box> </Box>
); );
@@ -100,7 +94,7 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
if (!conceptData) { if (!conceptData) {
return ( return (
<Box p={4} textAlign="center" color={mutedColor}> <Box p={4} textAlign="center" color="gray.500">
</Box> </Box>
); );
@@ -112,120 +106,203 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
<Box p={4}> <Box p={4}>
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{/* 概念涨跌幅 */} {/* 概念涨跌幅 */}
<Box bg={cardBg} p={4} borderRadius="md"> <motion.div
<HStack justify="space-between" align="flex-start"> initial={{ opacity: 0, y: 10 }}
<Box> animate={{ opacity: 1, y: 0 }}
<Text fontSize="lg" fontWeight="bold" color={textColor}> >
{conceptData.name} <Box
</Text> bg="rgba(255, 255, 255, 0.03)"
<Text fontSize="xs" color={mutedColor}> border="1px solid"
{conceptData.stockCount} borderColor="rgba(255, 255, 255, 0.08)"
</Text> p={4}
</Box> borderRadius="xl"
<Stat textAlign="right" size="sm"> >
<StatNumber color={isUp ? 'green.500' : 'red.500'}> <HStack justify="space-between" align="flex-start">
{isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}% <Box>
</StatNumber> <Text fontSize="lg" fontWeight="bold" color="white">
<StatHelpText mb={0}> {conceptData.name}
<StatArrow type={isUp ? 'increase' : 'decrease'} /> </Text>
{conceptData.change.toFixed(2)} <Text fontSize="xs" color="gray.500">
</StatHelpText> {conceptData.stockCount}
</Stat> </Text>
</HStack> </Box>
<Stat textAlign="right" size="sm">
{/* 涨跌分布 */} <StatNumber
<HStack mt={3} spacing={4}> color={isUp ? 'green.400' : 'red.400'}
<HStack> fontSize="xl"
<Box w="8px" h="8px" bg="green.500" borderRadius="full" />
<Text fontSize="xs" color={mutedColor}>
{conceptData.upCount}
</Text>
</HStack>
<HStack>
<Box w="8px" h="8px" bg="gray.400" borderRadius="full" />
<Text fontSize="xs" color={mutedColor}>
{conceptData.flatCount}
</Text>
</HStack>
<HStack>
<Box w="8px" h="8px" bg="red.500" borderRadius="full" />
<Text fontSize="xs" color={mutedColor}>
{conceptData.downCount}
</Text>
</HStack>
</HStack>
</Box>
<Divider />
{/* 领涨股票 */}
<Box>
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}>
</Text>
<VStack spacing={2} align="stretch">
{conceptData.leadingStocks.map((stock, index) => (
<HStack
key={stock.symbol}
justify="space-between"
py={1}
px={2}
bg={cardBg}
borderRadius="md"
cursor="pointer"
_hover={{ opacity: 0.8 }}
>
<HStack>
<Text fontSize="xs" color={mutedColor} w="16px">
{index + 1}
</Text>
<Box>
<Text fontSize="sm" fontWeight="medium">
{stock.name}
</Text>
<Text fontSize="xs" color={mutedColor}>
{stock.symbol}
</Text>
</Box>
</HStack>
<Text
fontSize="sm"
fontWeight="bold" fontWeight="bold"
color={stock.change >= 0 ? 'green.500' : 'red.500'}
> >
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}% {isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}%
</StatNumber>
<StatHelpText mb={0} color="gray.500">
<StatArrow type={isUp ? 'increase' : 'decrease'} />
{conceptData.change.toFixed(2)}
</StatHelpText>
</Stat>
</HStack>
{/* 涨跌分布 */}
<HStack mt={3} spacing={4}>
<HStack>
<Box
w="8px"
h="8px"
bg="green.400"
borderRadius="full"
boxShadow="0 0 8px rgba(74, 222, 128, 0.6)"
/>
<Text fontSize="xs" color="gray.400">
{conceptData.upCount}
</Text> </Text>
</HStack> </HStack>
))} <HStack>
</VStack> <Box w="8px" h="8px" bg="gray.500" borderRadius="full" />
</Box> <Text fontSize="xs" color="gray.400">
{conceptData.flatCount}
</Text>
</HStack>
<HStack>
<Box
w="8px"
h="8px"
bg="red.400"
borderRadius="full"
boxShadow="0 0 8px rgba(248, 113, 113, 0.6)"
/>
<Text fontSize="xs" color="gray.400">
{conceptData.downCount}
</Text>
</HStack>
</HStack>
</Box>
</motion.div>
<Divider /> <Divider borderColor="rgba(255, 255, 255, 0.08)" />
{/* 领涨股票 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Box>
<HStack mb={3}>
<Icon as={TrendingUp} boxSize={4} color="green.400" />
<Text fontSize="sm" fontWeight="bold" color="white">
</Text>
</HStack>
<VStack spacing={2} align="stretch">
{conceptData.leadingStocks.map((stock, index) => (
<motion.div
key={stock.symbol}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + index * 0.05 }}
>
<HStack
justify="space-between"
py={2}
px={3}
bg="rgba(255, 255, 255, 0.03)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.05)"
borderRadius="lg"
cursor="pointer"
_hover={{
bg: 'whiteAlpha.100',
borderColor: 'rgba(139, 92, 246, 0.3)',
}}
transition="all 0.2s"
>
<HStack>
<Text
fontSize="xs"
color="gray.600"
w="16px"
fontWeight="bold"
>
{index + 1}
</Text>
<Box>
<Text fontSize="sm" fontWeight="medium" color="white">
{stock.name}
</Text>
<Text fontSize="xs" color="gray.500">
{stock.symbol}
</Text>
</Box>
</HStack>
<Text
fontSize="sm"
fontWeight="bold"
color={stock.change >= 0 ? 'green.400' : 'red.400'}
>
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}%
</Text>
</HStack>
</motion.div>
))}
</VStack>
</Box>
</motion.div>
<Divider borderColor="rgba(255, 255, 255, 0.08)" />
{/* 相关概念 */} {/* 相关概念 */}
<Box> <motion.div
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}> initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
</Text> transition={{ delay: 0.3 }}
<HStack spacing={2} flexWrap="wrap"> >
{conceptData.relatedConcepts.map(concept => ( <Box>
<Tag <Text fontSize="sm" fontWeight="bold" mb={2} color="white">
key={concept}
size="sm" </Text>
colorScheme="blue" <HStack spacing={2} flexWrap="wrap">
variant="subtle" {conceptData.relatedConcepts.map(concept => (
cursor="pointer" <Tag
> key={concept}
{concept} size="sm"
</Tag> bg="rgba(59, 130, 246, 0.15)"
))} color="blue.300"
</HStack> border="1px solid"
</Box> borderColor="rgba(59, 130, 246, 0.3)"
cursor="pointer"
_hover={{
bg: 'rgba(59, 130, 246, 0.25)',
borderColor: 'rgba(59, 130, 246, 0.5)',
}}
transition="all 0.2s"
>
{concept}
</Tag>
))}
</HStack>
</Box>
</motion.div>
{/* 查看更多 */} {/* 查看更多 */}
<Button size="sm" variant="outline" colorScheme="blue"> <motion.div
initial={{ opacity: 0, y: 10 }}
</Button> animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Button
size="sm"
w="full"
variant="outline"
borderColor="rgba(139, 92, 246, 0.4)"
color="purple.300"
rightIcon={<ExternalLink className="w-4 h-4" />}
_hover={{
bg: 'rgba(139, 92, 246, 0.1)',
borderColor: 'purple.400',
}}
>
</Button>
</motion.div>
</VStack> </VStack>
</Box> </Box>
); );

View File

@@ -1,5 +1,5 @@
/** /**
* 成员列表组件 * 成员列表组件 - HeroUI 深色风格
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
@@ -10,12 +10,13 @@ import {
Avatar, Avatar,
Badge, Badge,
Skeleton, Skeleton,
useColorModeValue,
Input, Input,
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
Icon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons'; import { Search } from 'lucide-react';
import { motion } from 'framer-motion';
import { CommunityMember } from '../../types'; import { CommunityMember } from '../../types';
@@ -40,11 +41,6 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const headerBg = useColorModeValue('gray.50', 'gray.900');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const textColor = useColorModeValue('gray.800', 'gray.100');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
// 加载成员 // 加载成员
useEffect(() => { useEffect(() => {
const loadMembers = async () => { const loadMembers = async () => {
@@ -68,55 +64,71 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
const offlineMembers = filteredMembers.filter(m => !m.isOnline); const offlineMembers = filteredMembers.filter(m => !m.isOnline);
// 渲染成员项 // 渲染成员项
const renderMember = (member: CommunityMember) => ( const renderMember = (member: CommunityMember, index: number) => (
<HStack <motion.div
key={member.userId} key={member.userId}
px={3} initial={{ opacity: 0, x: 10 }}
py={2} animate={{ opacity: 1, x: 0 }}
cursor="pointer" transition={{ delay: index * 0.03 }}
borderRadius="md"
_hover={{ bg: hoverBg }}
opacity={member.isOnline ? 1 : 0.6}
> >
<Box position="relative"> <HStack
<Avatar size="sm" name={member.username} src={member.avatar} /> px={3}
{member.isOnline && ( py={2}
<Box cursor="pointer"
position="absolute" borderRadius="lg"
bottom={0} _hover={{ bg: 'whiteAlpha.100' }}
right={0} opacity={member.isOnline ? 1 : 0.5}
w="10px" transition="all 0.2s"
h="10px" >
bg="green.400" <Box position="relative">
borderRadius="full" <Avatar
borderWidth="2px" size="sm"
borderColor={useColorModeValue('white', 'gray.800')} name={member.username}
src={member.avatar}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))"
/> />
)} {member.isOnline && (
</Box> <Box
position="absolute"
<Box flex={1}> bottom={0}
<HStack spacing={1}> right={0}
<Text fontSize="sm" fontWeight="medium" color={textColor}> w="10px"
{member.username} h="10px"
</Text> bg="green.400"
{member.badge && ( borderRadius="full"
<Badge borderWidth="2px"
size="sm" borderColor="rgba(17, 24, 39, 0.95)"
colorScheme={member.badge === '大V' ? 'yellow' : 'blue'} boxShadow="0 0 8px rgba(74, 222, 128, 0.6)"
fontSize="xs" />
>
{member.badge}
</Badge>
)} )}
</HStack> </Box>
{member.level && (
<Text fontSize="xs" color={mutedColor}> <Box flex={1}>
Lv.{member.level} <HStack spacing={1}>
</Text> <Text fontSize="sm" fontWeight="medium" color="white">
)} {member.username}
</Box> </Text>
</HStack> {member.badge && (
<Badge
size="sm"
bg={member.badge === '大V' ? 'rgba(251, 191, 36, 0.2)' : 'rgba(59, 130, 246, 0.2)'}
color={member.badge === '大V' ? 'yellow.300' : 'blue.300'}
fontSize="xs"
px={1.5}
borderRadius="full"
>
{member.badge}
</Badge>
)}
</HStack>
{member.level && (
<Text fontSize="xs" color="gray.500">
Lv.{member.level}
</Text>
)}
</Box>
</HStack>
</motion.div>
); );
if (loading) { if (loading) {
@@ -125,8 +137,19 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
<VStack spacing={3}> <VStack spacing={3}>
{[1, 2, 3, 4, 5].map(i => ( {[1, 2, 3, 4, 5].map(i => (
<HStack key={i} w="full"> <HStack key={i} w="full">
<Skeleton borderRadius="full" w="32px" h="32px" /> <Skeleton
<Skeleton h="20px" flex={1} /> borderRadius="full"
w="32px"
h="32px"
startColor="whiteAlpha.100"
endColor="whiteAlpha.200"
/>
<Skeleton
h="20px"
flex={1}
startColor="whiteAlpha.100"
endColor="whiteAlpha.200"
/>
</HStack> </HStack>
))} ))}
</VStack> </VStack>
@@ -137,16 +160,30 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
return ( return (
<Box> <Box>
{/* 搜索框 */} {/* 搜索框 */}
<Box p={3} bg={headerBg}> <Box
p={3}
bg="rgba(17, 24, 39, 0.6)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
>
<InputGroup size="sm"> <InputGroup size="sm">
<InputLeftElement> <InputLeftElement>
<SearchIcon color={mutedColor} /> <Icon as={Search} boxSize={4} color="gray.500" />
</InputLeftElement> </InputLeftElement>
<Input <Input
placeholder="搜索成员" placeholder="搜索成员"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => 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)',
}}
/> />
</InputGroup> </InputGroup>
</Box> </Box>
@@ -159,13 +196,13 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
py={2} py={2}
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
color={mutedColor} color="green.400"
textTransform="uppercase" textTransform="uppercase"
> >
线 {onlineMembers.length} 线 {onlineMembers.length}
</Text> </Text>
<VStack spacing={0} align="stretch"> <VStack spacing={0} align="stretch">
{onlineMembers.map(renderMember)} {onlineMembers.map((member, index) => renderMember(member, index))}
</VStack> </VStack>
</Box> </Box>
)} )}
@@ -178,20 +215,20 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
py={2} py={2}
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
color={mutedColor} color="gray.500"
textTransform="uppercase" textTransform="uppercase"
> >
线 {offlineMembers.length} 线 {offlineMembers.length}
</Text> </Text>
<VStack spacing={0} align="stretch"> <VStack spacing={0} align="stretch">
{offlineMembers.map(renderMember)} {offlineMembers.map((member, index) => renderMember(member, onlineMembers.length + index))}
</VStack> </VStack>
</Box> </Box>
)} )}
{/* 空状态 */} {/* 空状态 */}
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<Box textAlign="center" py={8} color={mutedColor}> <Box textAlign="center" py={8} color="gray.500">
{searchTerm ? '未找到匹配的成员' : '暂无成员'} {searchTerm ? '未找到匹配的成员' : '暂无成员'}
</Box> </Box>
)} )}

View File

@@ -1,5 +1,5 @@
/** /**
* 讨论串列表组件 * 讨论串列表组件 - HeroUI 深色风格
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
@@ -7,14 +7,13 @@ import {
VStack, VStack,
HStack, HStack,
Text, Text,
Badge,
Skeleton, Skeleton,
useColorModeValue,
Icon, Icon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { MdForum, MdPushPin } from 'react-icons/md'; import { MessageSquare, Pin } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; import { zhCN } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { Thread } from '../../types'; import { Thread } from '../../types';
@@ -75,10 +74,6 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
const [threads, setThreads] = useState<Thread[]>([]); const [threads, setThreads] = useState<Thread[]>([]);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
const loadThreads = async () => { const loadThreads = async () => {
@@ -101,37 +96,54 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
}; };
// 渲染讨论串项 // 渲染讨论串项
const renderThread = (thread: Thread) => ( const renderThread = (thread: Thread, index: number) => (
<Box <motion.div
key={thread.id} key={thread.id}
px={3} initial={{ opacity: 0, y: 10 }}
py={3} animate={{ opacity: 1, y: 0 }}
cursor="pointer" transition={{ delay: index * 0.05 }}
borderRadius="md"
_hover={{ bg: hoverBg }}
> >
<HStack spacing={2} mb={1}> <Box
{thread.isPinned && ( px={3}
<Icon as={MdPushPin} color="blue.500" boxSize={4} /> py={3}
)} cursor="pointer"
<Icon as={MdForum} color={mutedColor} boxSize={4} /> borderRadius="lg"
<Text _hover={{ bg: 'whiteAlpha.100' }}
fontSize="sm" transition="all 0.2s"
fontWeight="medium" >
color={textColor} <HStack spacing={2} mb={1}>
flex={1} {thread.isPinned && (
isTruncated <Icon as={Pin} color="purple.400" boxSize={4} />
> )}
{thread.name} <Icon as={MessageSquare} color="gray.500" boxSize={4} />
</Text> <Text
</HStack> fontSize="sm"
fontWeight="medium"
color="white"
flex={1}
isTruncated
>
{thread.name}
</Text>
</HStack>
<HStack spacing={3} pl={6} fontSize="xs" color={mutedColor}> <HStack spacing={3} pl={6} fontSize="xs" color="gray.500">
<Text>{thread.messageCount} </Text> <HStack spacing={1}>
<Text>·</Text> <Text
<Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text> color="purple.300"
</HStack> bg="rgba(139, 92, 246, 0.15)"
</Box> px={1.5}
borderRadius="sm"
>
{thread.messageCount}
</Text>
<Text></Text>
</HStack>
<Text>·</Text>
<Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text>
</HStack>
</Box>
</motion.div>
); );
if (loading) { if (loading) {
@@ -140,8 +152,18 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
<VStack spacing={3}> <VStack spacing={3}>
{[1, 2, 3].map(i => ( {[1, 2, 3].map(i => (
<Box key={i} w="full"> <Box key={i} w="full">
<Skeleton h="20px" mb={2} /> <Skeleton
<Skeleton h="14px" w="60%" /> h="20px"
mb={2}
startColor="whiteAlpha.100"
endColor="whiteAlpha.200"
/>
<Skeleton
h="14px"
w="60%"
startColor="whiteAlpha.100"
endColor="whiteAlpha.200"
/>
</Box> </Box>
))} ))}
</VStack> </VStack>
@@ -163,13 +185,13 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
py={2} py={2}
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
color={mutedColor} color="purple.400"
textTransform="uppercase" textTransform="uppercase"
> >
</Text> </Text>
<VStack spacing={0} align="stretch"> <VStack spacing={0} align="stretch">
{pinnedThreads.map(renderThread)} {pinnedThreads.map((thread, index) => renderThread(thread, index))}
</VStack> </VStack>
</Box> </Box>
)} )}
@@ -182,23 +204,34 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
py={2} py={2}
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
color={mutedColor} color="gray.500"
textTransform="uppercase" textTransform="uppercase"
> >
</Text> </Text>
<VStack spacing={0} align="stretch"> <VStack spacing={0} align="stretch">
{activeThreads.map(renderThread)} {activeThreads.map((thread, index) =>
renderThread(thread, pinnedThreads.length + index)
)}
</VStack> </VStack>
</Box> </Box>
)} )}
{/* 空状态 */} {/* 空状态 */}
{threads.length === 0 && ( {threads.length === 0 && (
<Box textAlign="center" py={8} color={mutedColor}> <Box textAlign="center" py={8}>
<Icon as={MdForum} boxSize={8} mb={2} /> <Box
<Text></Text> mx="auto"
<Text fontSize="xs" mt={1}> w="fit-content"
p={3}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.2))"
borderRadius="full"
mb={3}
>
<Icon as={MessageSquare} boxSize={6} color="purple.400" />
</Box>
<Text color="gray.500"></Text>
<Text fontSize="xs" color="gray.600" mt={1}>
"创建讨论串" "创建讨论串"
</Text> </Text>
</Box> </Box>

View File

@@ -1,5 +1,5 @@
/** /**
* 右侧面板组件 * 右侧面板组件 - HeroUI 深色风格
* 显示成员列表 / 概念详情 / 讨论串 * 显示成员列表 / 概念详情 / 讨论串
*/ */
import React from 'react'; import React from 'react';
@@ -10,13 +10,18 @@ import {
TabPanels, TabPanels,
Tab, Tab,
TabPanel, TabPanel,
useColorModeValue, Text,
Icon,
VStack,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Users, Info, MessageSquare } from 'lucide-react';
import { motion } from 'framer-motion';
import { Channel } from '../../types'; import { Channel } from '../../types';
import MemberList from './MemberList'; import MemberList from './MemberList';
import ConceptInfo from './ConceptInfo'; import ConceptInfo from './ConceptInfo';
import ThreadList from './ThreadList'; import ThreadList from './ThreadList';
import { GLASS_BLUR } from '@/constants/glassConfig';
interface RightPanelProps { interface RightPanelProps {
channel: Channel | null; channel: Channel | null;
@@ -29,9 +34,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
contentType, contentType,
onContentTypeChange, onContentTypeChange,
}) => { }) => {
const bgColor = useColorModeValue('white', 'gray.800');
const headerBg = useColorModeValue('gray.50', 'gray.900');
// Tab 索引映射 // Tab 索引映射
const tabIndexMap = { const tabIndexMap = {
members: 0, members: 0,
@@ -46,10 +48,24 @@ const RightPanel: React.FC<RightPanelProps> = ({
if (!channel) { if (!channel) {
return ( return (
<Box h="full" bg={bgColor} p={4}> <Box
<Box color="gray.500" textAlign="center" mt={10}> h="full"
bg="rgba(17, 24, 39, 0.95)"
</Box> backdropFilter={GLASS_BLUR.lg}
p={4}
>
<VStack spacing={4} justify="center" h="full">
<Box
p={4}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(59, 130, 246, 0.2))"
borderRadius="full"
>
<Icon as={Info} boxSize={8} color="purple.400" />
</Box>
<Text color="gray.500" textAlign="center">
</Text>
</VStack>
</Box> </Box>
); );
} }
@@ -58,38 +74,147 @@ const RightPanel: React.FC<RightPanelProps> = ({
const isConceptChannel = !!channel.conceptCode; const isConceptChannel = !!channel.conceptCode;
return ( return (
<Box h="full" bg={bgColor} display="flex" flexDirection="column"> <Box
h="full"
bg="rgba(17, 24, 39, 0.95)"
backdropFilter={GLASS_BLUR.lg}
display="flex"
flexDirection="column"
>
<Tabs <Tabs
index={tabIndexMap[contentType]} index={tabIndexMap[contentType]}
onChange={handleTabChange} onChange={handleTabChange}
variant="enclosed" variant="unstyled"
size="sm" size="sm"
h="full" h="full"
display="flex" display="flex"
flexDirection="column" flexDirection="column"
> >
<TabList bg={headerBg} px={2} pt={2}> <TabList
<Tab fontSize="xs"></Tab> bg="rgba(17, 24, 39, 0.6)"
{isConceptChannel && <Tab fontSize="xs"></Tab>} px={2}
<Tab fontSize="xs"></Tab> pt={2}
pb={1}
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
gap={1}
>
<Tab
fontSize="xs"
color="gray.400"
px={3}
py={2}
borderRadius="lg"
_selected={{
color: 'white',
bg: 'rgba(139, 92, 246, 0.2)',
}}
_hover={{
color: 'white',
bg: 'whiteAlpha.100',
}}
transition="all 0.2s"
>
<Icon as={Users} boxSize={4} mr={1.5} />
</Tab>
{isConceptChannel && (
<Tab
fontSize="xs"
color="gray.400"
px={3}
py={2}
borderRadius="lg"
_selected={{
color: 'white',
bg: 'rgba(139, 92, 246, 0.2)',
}}
_hover={{
color: 'white',
bg: 'whiteAlpha.100',
}}
transition="all 0.2s"
>
<Icon as={Info} boxSize={4} mr={1.5} />
</Tab>
)}
<Tab
fontSize="xs"
color="gray.400"
px={3}
py={2}
borderRadius="lg"
_selected={{
color: 'white',
bg: 'rgba(139, 92, 246, 0.2)',
}}
_hover={{
color: 'white',
bg: 'whiteAlpha.100',
}}
transition="all 0.2s"
>
<Icon as={MessageSquare} boxSize={4} mr={1.5} />
</Tab>
</TabList> </TabList>
<TabPanels flex={1} overflow="hidden"> <TabPanels
flex={1}
overflow="hidden"
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255, 255, 255, 0.02)',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(139, 92, 246, 0.2)',
borderRadius: '3px',
'&:hover': {
background: 'rgba(139, 92, 246, 0.4)',
},
},
}}
>
{/* 成员列表 */} {/* 成员列表 */}
<TabPanel h="full" p={0} overflow="auto"> <TabPanel h="full" p={0} overflow="auto">
<MemberList channelId={channel.id} /> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<MemberList channelId={channel.id} />
</motion.div>
</TabPanel> </TabPanel>
{/* 概念详情(仅概念频道) */} {/* 概念详情(仅概念频道) */}
{isConceptChannel && ( {isConceptChannel && (
<TabPanel h="full" p={0} overflow="auto"> <TabPanel h="full" p={0} overflow="auto">
<ConceptInfo conceptCode={channel.conceptCode!} /> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<ConceptInfo conceptCode={channel.conceptCode!} />
</motion.div>
</TabPanel> </TabPanel>
)} )}
{/* 讨论串列表 */} {/* 讨论串列表 */}
<TabPanel h="full" p={0} overflow="auto"> <TabPanel h="full" p={0} overflow="auto">
<ThreadList channelId={channel.id} /> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<ThreadList channelId={channel.id} />
</motion.div>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>

View File

@@ -61,6 +61,33 @@ export const unsubscribeChannel = async (channelId: string): Promise<void> => {
if (!response.ok) throw new Error('取消订阅失败'); 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<Channel> => {
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;
};
// ============================================================ // ============================================================
// 消息相关(即时聊天) // 消息相关(即时聊天)
// ============================================================ // ============================================================