个股论坛重做

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,16 +33,21 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
}; };
return ( return (
<motion.div whileHover={{ scale: 1.005 }} whileTap={{ scale: 0.995 }}>
<Box <Box
bg={bgColor} bg="rgba(255, 255, 255, 0.03)"
borderWidth="1px" border="1px solid"
borderColor={borderColor} borderColor="rgba(255, 255, 255, 0.08)"
borderRadius="lg" borderRadius="xl"
p={4} p={4}
mb={3} mb={3}
cursor="pointer" cursor="pointer"
transition="all 0.2s" transition="all 0.2s"
_hover={{ bg: hoverBg, transform: 'translateY(-1px)', shadow: 'sm' }} _hover={{
bg: 'rgba(255, 255, 255, 0.06)',
borderColor: 'rgba(139, 92, 246, 0.3)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
}}
onClick={onClick} onClick={onClick}
> >
<Flex> <Flex>
@@ -58,6 +57,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
name={post.authorName} name={post.authorName}
src={post.authorAvatar} src={post.authorAvatar}
mr={3} mr={3}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))"
/> />
{/* 帖子内容 */} {/* 帖子内容 */}
@@ -65,15 +65,15 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
{/* 标题和标记 */} {/* 标题和标记 */}
<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 <Text
fontWeight="bold" fontWeight="bold"
fontSize="md" fontSize="md"
color={textColor} color="white"
noOfLines={1} noOfLines={1}
> >
{post.title} {post.title}
@@ -82,7 +82,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
{/* 内容预览 */} {/* 内容预览 */}
<Text <Text
color={mutedColor} color="gray.400"
fontSize="sm" fontSize="sm"
noOfLines={2} noOfLines={2}
mb={2} mb={2}
@@ -94,12 +94,19 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<HStack spacing={1} mb={2} flexWrap="wrap"> <HStack spacing={1} mb={2} flexWrap="wrap">
{post.tags.slice(0, 3).map(tag => ( {post.tags.slice(0, 3).map(tag => (
<Tag key={tag} size="sm" colorScheme="blue" variant="subtle"> <Tag
key={tag}
size="sm"
bg="rgba(59, 130, 246, 0.15)"
color="blue.300"
border="1px solid"
borderColor="rgba(59, 130, 246, 0.3)"
>
{tag} {tag}
</Tag> </Tag>
))} ))}
{post.tags.length > 3 && ( {post.tags.length > 3 && (
<Text fontSize="xs" color={mutedColor}> <Text fontSize="xs" color="gray.500">
+{post.tags.length - 3} +{post.tags.length - 3}
</Text> </Text>
)} )}
@@ -109,8 +116,8 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
{/* 底部信息 */} {/* 底部信息 */}
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
{/* 作者和时间 */} {/* 作者和时间 */}
<HStack spacing={2} fontSize="sm" color={mutedColor}> <HStack spacing={2} fontSize="sm" color="gray.500">
<Text fontWeight="medium">{post.authorName}</Text> <Text fontWeight="medium" color="purple.300">{post.authorName}</Text>
<Text>·</Text> <Text>·</Text>
<Text>{formatTime(post.createdAt)}</Text> <Text>{formatTime(post.createdAt)}</Text>
{post.lastReplyAt && post.lastReplyAt !== post.createdAt && ( {post.lastReplyAt && post.lastReplyAt !== post.createdAt && (
@@ -122,17 +129,26 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
</HStack> </HStack>
{/* 统计数据 */} {/* 统计数据 */}
<HStack spacing={4} fontSize="sm" color={mutedColor}> <HStack spacing={4} fontSize="sm" color="gray.500">
<HStack spacing={1}> <HStack spacing={1}>
<Icon as={MdChat} boxSize={4} /> <Icon as={MessageCircle} boxSize={4} />
<Text>{post.replyCount}</Text> <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>
<HStack spacing={1}> <HStack spacing={1}>
<Icon as={MdVisibility} boxSize={4} /> <Icon as={Eye} boxSize={4} />
<Text>{post.viewCount}</Text> <Text>{post.viewCount}</Text>
</HStack> </HStack>
<HStack spacing={1}> <HStack spacing={1}>
<Icon as={MdThumbUp} boxSize={4} /> <Icon as={ThumbsUp} boxSize={4} />
<Text>{post.likeCount}</Text> <Text>{post.likeCount}</Text>
</HStack> </HStack>
</HStack> </HStack>
@@ -140,6 +156,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
</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)"
> >
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button <Button
leftIcon={<MdAdd />} leftIcon={<Plus className="w-4 h-4" />}
colorScheme="blue"
size="sm" size="sm"
bgGradient="linear(to-r, purple.500, blue.500)"
color="white"
_hover={{
bgGradient: 'linear(to-r, purple.600, blue.600)',
boxShadow: '0 0 20px rgba(139, 92, 246, 0.4)',
}}
onClick={onCreateOpen} onClick={onCreateOpen}
> >
</Button> </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 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex <Flex
direction="column" direction="column"
align="center" align="center"
justify="center" justify="center"
h="200px" py={12}
color="gray.500" >
<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}
> >
<Text mb={2}></Text>
<Button size="sm" colorScheme="blue" onClick={onCreateOpen}>
</Button> </Button>
</motion.div>
</Flex> </Flex>
</motion.div>
) : ( ) : (
<> <>
{posts.map(post => ( {posts.map((post, index) => (
<PostCard <motion.div
key={post.id} key={post.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<PostCard
post={post} post={post}
onClick={() => setSelectedPost(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,21 +106,35 @@ 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
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<Box
bg="rgba(255, 255, 255, 0.03)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
p={4}
borderRadius="xl"
>
<HStack justify="space-between" align="flex-start"> <HStack justify="space-between" align="flex-start">
<Box> <Box>
<Text fontSize="lg" fontWeight="bold" color={textColor}> <Text fontSize="lg" fontWeight="bold" color="white">
{conceptData.name} {conceptData.name}
</Text> </Text>
<Text fontSize="xs" color={mutedColor}> <Text fontSize="xs" color="gray.500">
{conceptData.stockCount} {conceptData.stockCount}
</Text> </Text>
</Box> </Box>
<Stat textAlign="right" size="sm"> <Stat textAlign="right" size="sm">
<StatNumber color={isUp ? 'green.500' : 'red.500'}> <StatNumber
color={isUp ? 'green.400' : 'red.400'}
fontSize="xl"
fontWeight="bold"
>
{isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}% {isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}%
</StatNumber> </StatNumber>
<StatHelpText mb={0}> <StatHelpText mb={0} color="gray.500">
<StatArrow type={isUp ? 'increase' : 'decrease'} /> <StatArrow type={isUp ? 'increase' : 'decrease'} />
{conceptData.change.toFixed(2)} {conceptData.change.toFixed(2)}
</StatHelpText> </StatHelpText>
@@ -136,54 +144,91 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
{/* 涨跌分布 */} {/* 涨跌分布 */}
<HStack mt={3} spacing={4}> <HStack mt={3} spacing={4}>
<HStack> <HStack>
<Box w="8px" h="8px" bg="green.500" borderRadius="full" /> <Box
<Text fontSize="xs" color={mutedColor}> 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} {conceptData.upCount}
</Text> </Text>
</HStack> </HStack>
<HStack> <HStack>
<Box w="8px" h="8px" bg="gray.400" borderRadius="full" /> <Box w="8px" h="8px" bg="gray.500" borderRadius="full" />
<Text fontSize="xs" color={mutedColor}> <Text fontSize="xs" color="gray.400">
{conceptData.flatCount} {conceptData.flatCount}
</Text> </Text>
</HStack> </HStack>
<HStack> <HStack>
<Box w="8px" h="8px" bg="red.500" borderRadius="full" /> <Box
<Text fontSize="xs" color={mutedColor}> 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} {conceptData.downCount}
</Text> </Text>
</HStack> </HStack>
</HStack> </HStack>
</Box> </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> <Box>
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}> <HStack mb={3}>
<Icon as={TrendingUp} boxSize={4} color="green.400" />
<Text fontSize="sm" fontWeight="bold" color="white">
</Text> </Text>
</HStack>
<VStack spacing={2} align="stretch"> <VStack spacing={2} align="stretch">
{conceptData.leadingStocks.map((stock, index) => ( {conceptData.leadingStocks.map((stock, index) => (
<HStack <motion.div
key={stock.symbol} key={stock.symbol}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + index * 0.05 }}
>
<HStack
justify="space-between" justify="space-between"
py={1} py={2}
px={2} px={3}
bg={cardBg} bg="rgba(255, 255, 255, 0.03)"
borderRadius="md" border="1px solid"
borderColor="rgba(255, 255, 255, 0.05)"
borderRadius="lg"
cursor="pointer" cursor="pointer"
_hover={{ opacity: 0.8 }} _hover={{
bg: 'whiteAlpha.100',
borderColor: 'rgba(139, 92, 246, 0.3)',
}}
transition="all 0.2s"
> >
<HStack> <HStack>
<Text fontSize="xs" color={mutedColor} w="16px"> <Text
fontSize="xs"
color="gray.600"
w="16px"
fontWeight="bold"
>
{index + 1} {index + 1}
</Text> </Text>
<Box> <Box>
<Text fontSize="sm" fontWeight="medium"> <Text fontSize="sm" fontWeight="medium" color="white">
{stock.name} {stock.name}
</Text> </Text>
<Text fontSize="xs" color={mutedColor}> <Text fontSize="xs" color="gray.500">
{stock.symbol} {stock.symbol}
</Text> </Text>
</Box> </Box>
@@ -191,20 +236,27 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
<Text <Text
fontSize="sm" fontSize="sm"
fontWeight="bold" fontWeight="bold"
color={stock.change >= 0 ? 'green.500' : 'red.500'} color={stock.change >= 0 ? 'green.400' : 'red.400'}
> >
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}% {stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}%
</Text> </Text>
</HStack> </HStack>
</motion.div>
))} ))}
</VStack> </VStack>
</Box> </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.3 }}
>
<Box> <Box>
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}> <Text fontSize="sm" fontWeight="bold" mb={2} color="white">
</Text> </Text>
<HStack spacing={2} flexWrap="wrap"> <HStack spacing={2} flexWrap="wrap">
@@ -212,20 +264,45 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
<Tag <Tag
key={concept} key={concept}
size="sm" size="sm"
colorScheme="blue" bg="rgba(59, 130, 246, 0.15)"
variant="subtle" color="blue.300"
border="1px solid"
borderColor="rgba(59, 130, 246, 0.3)"
cursor="pointer" cursor="pointer"
_hover={{
bg: 'rgba(59, 130, 246, 0.25)',
borderColor: 'rgba(59, 130, 246, 0.5)',
}}
transition="all 0.2s"
> >
{concept} {concept}
</Tag> </Tag>
))} ))}
</HStack> </HStack>
</Box> </Box>
</motion.div>
{/* 查看更多 */} {/* 查看更多 */}
<Button size="sm" variant="outline" colorScheme="blue"> <motion.div
initial={{ opacity: 0, y: 10 }}
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> </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,18 +64,29 @@ 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}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.03 }}
>
<HStack
px={3} px={3}
py={2} py={2}
cursor="pointer" cursor="pointer"
borderRadius="md" borderRadius="lg"
_hover={{ bg: hoverBg }} _hover={{ bg: 'whiteAlpha.100' }}
opacity={member.isOnline ? 1 : 0.6} opacity={member.isOnline ? 1 : 0.5}
transition="all 0.2s"
> >
<Box position="relative"> <Box position="relative">
<Avatar size="sm" name={member.username} src={member.avatar} /> <Avatar
size="sm"
name={member.username}
src={member.avatar}
bg="linear-gradient(135deg, rgba(139, 92, 246, 0.6), rgba(59, 130, 246, 0.6))"
/>
{member.isOnline && ( {member.isOnline && (
<Box <Box
position="absolute" position="absolute"
@@ -90,33 +97,38 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
bg="green.400" bg="green.400"
borderRadius="full" borderRadius="full"
borderWidth="2px" borderWidth="2px"
borderColor={useColorModeValue('white', 'gray.800')} borderColor="rgba(17, 24, 39, 0.95)"
boxShadow="0 0 8px rgba(74, 222, 128, 0.6)"
/> />
)} )}
</Box> </Box>
<Box flex={1}> <Box flex={1}>
<HStack spacing={1}> <HStack spacing={1}>
<Text fontSize="sm" fontWeight="medium" color={textColor}> <Text fontSize="sm" fontWeight="medium" color="white">
{member.username} {member.username}
</Text> </Text>
{member.badge && ( {member.badge && (
<Badge <Badge
size="sm" size="sm"
colorScheme={member.badge === '大V' ? 'yellow' : 'blue'} 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" fontSize="xs"
px={1.5}
borderRadius="full"
> >
{member.badge} {member.badge}
</Badge> </Badge>
)} )}
</HStack> </HStack>
{member.level && ( {member.level && (
<Text fontSize="xs" color={mutedColor}> <Text fontSize="xs" color="gray.500">
Lv.{member.level} Lv.{member.level}
</Text> </Text>
)} )}
</Box> </Box>
</HStack> </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,24 +96,30 @@ 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}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Box
px={3} px={3}
py={3} py={3}
cursor="pointer" cursor="pointer"
borderRadius="md" borderRadius="lg"
_hover={{ bg: hoverBg }} _hover={{ bg: 'whiteAlpha.100' }}
transition="all 0.2s"
> >
<HStack spacing={2} mb={1}> <HStack spacing={2} mb={1}>
{thread.isPinned && ( {thread.isPinned && (
<Icon as={MdPushPin} color="blue.500" boxSize={4} /> <Icon as={Pin} color="purple.400" boxSize={4} />
)} )}
<Icon as={MdForum} color={mutedColor} boxSize={4} /> <Icon as={MessageSquare} color="gray.500" boxSize={4} />
<Text <Text
fontSize="sm" fontSize="sm"
fontWeight="medium" fontWeight="medium"
color={textColor} color="white"
flex={1} flex={1}
isTruncated isTruncated
> >
@@ -126,12 +127,23 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
</Text> </Text>
</HStack> </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
color="purple.300"
bg="rgba(139, 92, 246, 0.15)"
px={1.5}
borderRadius="sm"
>
{thread.messageCount}
</Text>
<Text></Text>
</HStack>
<Text>·</Text> <Text>·</Text>
<Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text> <Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text>
</HStack> </HStack>
</Box> </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)"
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> </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">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<MemberList channelId={channel.id} /> <MemberList channelId={channel.id} />
</motion.div>
</TabPanel> </TabPanel>
{/* 概念详情(仅概念频道) */} {/* 概念详情(仅概念频道) */}
{isConceptChannel && ( {isConceptChannel && (
<TabPanel h="full" p={0} overflow="auto"> <TabPanel h="full" p={0} overflow="auto">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<ConceptInfo conceptCode={channel.conceptCode!} /> <ConceptInfo conceptCode={channel.conceptCode!} />
</motion.div>
</TabPanel> </TabPanel>
)} )}
{/* 讨论串列表 */} {/* 讨论串列表 */}
<TabPanel h="full" p={0} overflow="auto"> <TabPanel h="full" p={0} overflow="auto">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
style={{ height: '100%' }}
>
<ThreadList channelId={channel.id} /> <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;
};
// ============================================================ // ============================================================
// 消息相关(即时聊天) // 消息相关(即时聊天)
// ============================================================ // ============================================================