个股论坛重做
This commit is contained in:
104
community_api.py
104
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/<channel_id>/subscribe', methods=['POST'])
|
||||
@login_required
|
||||
def subscribe_channel(channel_id):
|
||||
|
||||
@@ -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<ChannelSidebarProps> = ({
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<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) => {
|
||||
return formatDistanceToNow(new Date(dateStr), {
|
||||
@@ -39,107 +33,130 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
mb={3}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: hoverBg, transform: 'translateY(-1px)', shadow: 'sm' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex>
|
||||
{/* 作者头像 */}
|
||||
<Avatar
|
||||
size="md"
|
||||
name={post.authorName}
|
||||
src={post.authorAvatar}
|
||||
mr={3}
|
||||
/>
|
||||
<motion.div whileHover={{ scale: 1.005 }} whileTap={{ scale: 0.995 }}>
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.08)"
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
mb={3}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_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}
|
||||
>
|
||||
<Flex>
|
||||
{/* 作者头像 */}
|
||||
<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}>
|
||||
{/* 标题和标记 */}
|
||||
<HStack spacing={2} mb={1}>
|
||||
{post.isPinned && (
|
||||
<Icon as={MdPushPin} color="blue.500" boxSize={4} />
|
||||
)}
|
||||
{post.isLocked && (
|
||||
<Icon as={MdLock} color="orange.500" boxSize={4} />
|
||||
)}
|
||||
{/* 帖子内容 */}
|
||||
<Box flex={1}>
|
||||
{/* 标题和标记 */}
|
||||
<HStack spacing={2} mb={1}>
|
||||
{post.isPinned && (
|
||||
<Icon as={Pin} color="purple.400" boxSize={4} />
|
||||
)}
|
||||
{post.isLocked && (
|
||||
<Icon as={Lock} color="orange.400" boxSize={4} />
|
||||
)}
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
color="white"
|
||||
noOfLines={1}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容预览 */}
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
color={textColor}
|
||||
noOfLines={1}
|
||||
color="gray.400"
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
mb={2}
|
||||
>
|
||||
{post.title}
|
||||
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容预览 */}
|
||||
<Text
|
||||
color={mutedColor}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
mb={2}
|
||||
>
|
||||
{post.content.replace(/<[^>]*>/g, '').slice(0, 150)}
|
||||
</Text>
|
||||
|
||||
{/* 标签 */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<HStack spacing={1} mb={2} flexWrap="wrap">
|
||||
{post.tags.slice(0, 3).map(tag => (
|
||||
<Tag key={tag} size="sm" colorScheme="blue" variant="subtle">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<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>
|
||||
{/* 标签 */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<HStack spacing={1} mb={2} flexWrap="wrap">
|
||||
{post.tags.slice(0, 3).map(tag => (
|
||||
<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>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
+{post.tags.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</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 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>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ForumChannelProps> = ({
|
||||
|
||||
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<ForumChannelProps> = ({
|
||||
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<ForumChannelProps> = ({
|
||||
loadPosts(1);
|
||||
}, [loadPosts]);
|
||||
|
||||
// 排序变化时重新加载
|
||||
useEffect(() => {
|
||||
loadPosts(1);
|
||||
}, [sortBy, loadPosts]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMore) {
|
||||
@@ -119,20 +112,55 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="full">
|
||||
{/* 频道头部 */}
|
||||
<ChannelHeader
|
||||
channel={channel}
|
||||
rightActions={
|
||||
<>
|
||||
{/* 频道头部 - HeroUI 深色风格 */}
|
||||
<Box
|
||||
px={4}
|
||||
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
|
||||
aria-label="搜索"
|
||||
icon={<Icon as={MdSearch} />}
|
||||
icon={<Search className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="gray.400"
|
||||
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Flex
|
||||
@@ -140,25 +168,47 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
|
||||
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)"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<MdAdd />}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={onCreateOpen}
|
||||
>
|
||||
发布帖子
|
||||
</Button>
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
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}
|
||||
>
|
||||
发布帖子
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={MdSort} color="gray.500" />
|
||||
<Icon as={Filter} boxSize={4} color="gray.500" />
|
||||
<Select
|
||||
size="sm"
|
||||
w="120px"
|
||||
value={sortBy}
|
||||
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="hot">最热门</option>
|
||||
@@ -168,19 +218,42 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
|
||||
</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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{/* 置顶帖子 */}
|
||||
{pinnedPosts.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<HStack mb={2}>
|
||||
<Icon as={MdPushPin} color="blue.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold" color="blue.500">
|
||||
<HStack mb={3}>
|
||||
<Icon as={Pin} boxSize={4} color="purple.400" />
|
||||
<Text fontSize="sm" fontWeight="semibold" color="purple.300">
|
||||
置顶帖子
|
||||
</Text>
|
||||
</HStack>
|
||||
@@ -196,26 +269,57 @@ const ForumChannel: React.FC<ForumChannelProps> = ({
|
||||
|
||||
{/* 普通帖子 */}
|
||||
{posts.length === 0 && pinnedPosts.length === 0 ? (
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
h="200px"
|
||||
color="gray.500"
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Text mb={2}>暂无帖子</Text>
|
||||
<Button size="sm" colorScheme="blue" onClick={onCreateOpen}>
|
||||
发布第一篇帖子
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
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 => (
|
||||
<PostCard
|
||||
{posts.map((post, index) => (
|
||||
<motion.div
|
||||
key={post.id}
|
||||
post={post}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
/>
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
isLoading={loadingMore}
|
||||
onClick={handleLoadMore}
|
||||
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 概念详情组件
|
||||
* 概念详情组件 - HeroUI 深色风格
|
||||
* 显示概念板块的相关信息和成分股
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
@@ -9,18 +9,17 @@ import {
|
||||
HStack,
|
||||
Text,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Tag,
|
||||
Button,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdTrendingUp, MdTrendingDown } from 'react-icons/md';
|
||||
import { TrendingUp, ExternalLink } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ConceptInfoProps {
|
||||
conceptCode: string;
|
||||
@@ -48,11 +47,6 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
|
||||
const [conceptData, setConceptData] = useState<ConceptData | null>(null);
|
||||
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(() => {
|
||||
const loadConcept = async () => {
|
||||
@@ -90,9 +84,9 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
|
||||
return (
|
||||
<Box p={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Skeleton h="60px" />
|
||||
<Skeleton h="100px" />
|
||||
<Skeleton h="150px" />
|
||||
<Skeleton h="60px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
|
||||
<Skeleton h="100px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
|
||||
<Skeleton h="150px" startColor="whiteAlpha.100" endColor="whiteAlpha.200" />
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
@@ -100,7 +94,7 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
|
||||
|
||||
if (!conceptData) {
|
||||
return (
|
||||
<Box p={4} textAlign="center" color={mutedColor}>
|
||||
<Box p={4} textAlign="center" color="gray.500">
|
||||
加载失败
|
||||
</Box>
|
||||
);
|
||||
@@ -112,120 +106,203 @@ const ConceptInfo: React.FC<ConceptInfoProps> = ({ conceptCode }) => {
|
||||
<Box p={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 概念涨跌幅 */}
|
||||
<Box bg={cardBg} p={4} borderRadius="md">
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={textColor}>
|
||||
{conceptData.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
{conceptData.stockCount} 只成分股
|
||||
</Text>
|
||||
</Box>
|
||||
<Stat textAlign="right" size="sm">
|
||||
<StatNumber color={isUp ? 'green.500' : 'red.500'}>
|
||||
{isUp ? '+' : ''}{conceptData.changePercent.toFixed(2)}%
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<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.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"
|
||||
<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">
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color="white">
|
||||
{conceptData.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{conceptData.stockCount} 只成分股
|
||||
</Text>
|
||||
</Box>
|
||||
<Stat textAlign="right" size="sm">
|
||||
<StatNumber
|
||||
color={isUp ? 'green.400' : 'red.400'}
|
||||
fontSize="xl"
|
||||
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>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
<HStack>
|
||||
<Box w="8px" h="8px" bg="gray.500" borderRadius="full" />
|
||||
<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>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2} color={textColor}>
|
||||
相关概念
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{conceptData.relatedConcepts.map(concept => (
|
||||
<Tag
|
||||
key={concept}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
cursor="pointer"
|
||||
>
|
||||
{concept}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2} color="white">
|
||||
相关概念
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{conceptData.relatedConcepts.map(concept => (
|
||||
<Tag
|
||||
key={concept}
|
||||
size="sm"
|
||||
bg="rgba(59, 130, 246, 0.15)"
|
||||
color="blue.300"
|
||||
border="1px solid"
|
||||
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">
|
||||
查看概念详情
|
||||
</Button>
|
||||
<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>
|
||||
</motion.div>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
* 成员列表组件 - HeroUI 深色风格
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
import { Search } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { CommunityMember } from '../../types';
|
||||
|
||||
@@ -40,11 +41,6 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
const loadMembers = async () => {
|
||||
@@ -68,55 +64,71 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
const offlineMembers = filteredMembers.filter(m => !m.isOnline);
|
||||
|
||||
// 渲染成员项
|
||||
const renderMember = (member: CommunityMember) => (
|
||||
<HStack
|
||||
const renderMember = (member: CommunityMember, index: number) => (
|
||||
<motion.div
|
||||
key={member.userId}
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
opacity={member.isOnline ? 1 : 0.6}
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Box position="relative">
|
||||
<Avatar size="sm" name={member.username} src={member.avatar} />
|
||||
{member.isOnline && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
w="10px"
|
||||
h="10px"
|
||||
bg="green.400"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={useColorModeValue('white', 'gray.800')}
|
||||
<HStack
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
borderRadius="lg"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
opacity={member.isOnline ? 1 : 0.5}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Box position="relative">
|
||||
<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))"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
{member.username}
|
||||
</Text>
|
||||
{member.badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme={member.badge === '大V' ? 'yellow' : 'blue'}
|
||||
fontSize="xs"
|
||||
>
|
||||
{member.badge}
|
||||
</Badge>
|
||||
{member.isOnline && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
w="10px"
|
||||
h="10px"
|
||||
bg="green.400"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor="rgba(17, 24, 39, 0.95)"
|
||||
boxShadow="0 0 8px rgba(74, 222, 128, 0.6)"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{member.level && (
|
||||
<Text fontSize="xs" color={mutedColor}>
|
||||
Lv.{member.level}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="white">
|
||||
{member.username}
|
||||
</Text>
|
||||
{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) {
|
||||
@@ -125,8 +137,19 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
<VStack spacing={3}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<HStack key={i} w="full">
|
||||
<Skeleton borderRadius="full" w="32px" h="32px" />
|
||||
<Skeleton h="20px" flex={1} />
|
||||
<Skeleton
|
||||
borderRadius="full"
|
||||
w="32px"
|
||||
h="32px"
|
||||
startColor="whiteAlpha.100"
|
||||
endColor="whiteAlpha.200"
|
||||
/>
|
||||
<Skeleton
|
||||
h="20px"
|
||||
flex={1}
|
||||
startColor="whiteAlpha.100"
|
||||
endColor="whiteAlpha.200"
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -137,16 +160,30 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
return (
|
||||
<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">
|
||||
<InputLeftElement>
|
||||
<SearchIcon color={mutedColor} />
|
||||
<Icon as={Search} boxSize={4} color="gray.500" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索成员"
|
||||
value={searchTerm}
|
||||
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>
|
||||
</Box>
|
||||
@@ -159,13 +196,13 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
color="green.400"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
在线 — {onlineMembers.length}
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{onlineMembers.map(renderMember)}
|
||||
{onlineMembers.map((member, index) => renderMember(member, index))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -178,20 +215,20 @@ const MemberList: React.FC<MemberListProps> = ({ channelId }) => {
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
color="gray.500"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
离线 — {offlineMembers.length}
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{offlineMembers.map(renderMember)}
|
||||
{offlineMembers.map((member, index) => renderMember(member, onlineMembers.length + index))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{filteredMembers.length === 0 && (
|
||||
<Box textAlign="center" py={8} color={mutedColor}>
|
||||
<Box textAlign="center" py={8} color="gray.500">
|
||||
{searchTerm ? '未找到匹配的成员' : '暂无成员'}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -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<ThreadListProps> = ({ channelId }) => {
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
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<ThreadListProps> = ({ channelId }) => {
|
||||
};
|
||||
|
||||
// 渲染讨论串项
|
||||
const renderThread = (thread: Thread) => (
|
||||
<Box
|
||||
const renderThread = (thread: Thread, index: number) => (
|
||||
<motion.div
|
||||
key={thread.id}
|
||||
px={3}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<HStack spacing={2} mb={1}>
|
||||
{thread.isPinned && (
|
||||
<Icon as={MdPushPin} color="blue.500" boxSize={4} />
|
||||
)}
|
||||
<Icon as={MdForum} color={mutedColor} boxSize={4} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={textColor}
|
||||
flex={1}
|
||||
isTruncated
|
||||
>
|
||||
{thread.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
px={3}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
borderRadius="lg"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={2} mb={1}>
|
||||
{thread.isPinned && (
|
||||
<Icon as={Pin} color="purple.400" boxSize={4} />
|
||||
)}
|
||||
<Icon as={MessageSquare} color="gray.500" boxSize={4} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color="white"
|
||||
flex={1}
|
||||
isTruncated
|
||||
>
|
||||
{thread.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3} pl={6} fontSize="xs" color={mutedColor}>
|
||||
<Text>{thread.messageCount} 条消息</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
<HStack spacing={3} pl={6} fontSize="xs" color="gray.500">
|
||||
<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>{formatTime(thread.lastMessageAt || thread.createdAt)}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -140,8 +152,18 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
|
||||
<VStack spacing={3}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<Box key={i} w="full">
|
||||
<Skeleton h="20px" mb={2} />
|
||||
<Skeleton h="14px" w="60%" />
|
||||
<Skeleton
|
||||
h="20px"
|
||||
mb={2}
|
||||
startColor="whiteAlpha.100"
|
||||
endColor="whiteAlpha.200"
|
||||
/>
|
||||
<Skeleton
|
||||
h="14px"
|
||||
w="60%"
|
||||
startColor="whiteAlpha.100"
|
||||
endColor="whiteAlpha.200"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -163,13 +185,13 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
color="purple.400"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
置顶
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{pinnedThreads.map(renderThread)}
|
||||
{pinnedThreads.map((thread, index) => renderThread(thread, index))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -182,23 +204,34 @@ const ThreadList: React.FC<ThreadListProps> = ({ channelId }) => {
|
||||
py={2}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={mutedColor}
|
||||
color="gray.500"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
活跃讨论串
|
||||
</Text>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{activeThreads.map(renderThread)}
|
||||
{activeThreads.map((thread, index) =>
|
||||
renderThread(thread, pinnedThreads.length + index)
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{threads.length === 0 && (
|
||||
<Box textAlign="center" py={8} color={mutedColor}>
|
||||
<Icon as={MdForum} boxSize={8} mb={2} />
|
||||
<Text>暂无讨论串</Text>
|
||||
<Text fontSize="xs" mt={1}>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Box
|
||||
mx="auto"
|
||||
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>
|
||||
</Box>
|
||||
|
||||
@@ -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<RightPanelProps> = ({
|
||||
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<RightPanelProps> = ({
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<Box h="full" bg={bgColor} p={4}>
|
||||
<Box color="gray.500" textAlign="center" mt={10}>
|
||||
选择一个频道查看详情
|
||||
</Box>
|
||||
<Box
|
||||
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>
|
||||
<Text color="gray.500" textAlign="center">
|
||||
选择一个频道查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -58,38 +74,147 @@ const RightPanel: React.FC<RightPanelProps> = ({
|
||||
const isConceptChannel = !!channel.conceptCode;
|
||||
|
||||
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
|
||||
index={tabIndexMap[contentType]}
|
||||
onChange={handleTabChange}
|
||||
variant="enclosed"
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
h="full"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<TabList bg={headerBg} px={2} pt={2}>
|
||||
<Tab fontSize="xs">成员</Tab>
|
||||
{isConceptChannel && <Tab fontSize="xs">概念</Tab>}
|
||||
<Tab fontSize="xs">讨论串</Tab>
|
||||
<TabList
|
||||
bg="rgba(17, 24, 39, 0.6)"
|
||||
px={2}
|
||||
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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* 概念详情(仅概念频道) */}
|
||||
{isConceptChannel && (
|
||||
<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 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>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
@@ -61,6 +61,33 @@ export const unsubscribeChannel = async (channelId: string): Promise<void> => {
|
||||
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;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 消息相关(即时聊天)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user