个股论坛重做

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)
@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):

View File

@@ -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,
});

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
};
// ============================================================
// 消息相关(即时聊天)
// ============================================================