个股论坛重做

This commit is contained in:
2026-01-06 12:36:37 +08:00
parent cba57f5d6d
commit 42855274cc
5 changed files with 336 additions and 18 deletions

59
community_admin_setup.sql Normal file
View File

@@ -0,0 +1,59 @@
-- 社区管理员权限设置
-- 在 MySQL 中执行
-- 1. 创建管理员表(如果不存在)
CREATE TABLE IF NOT EXISTS community_admins (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
role ENUM('admin', 'moderator') DEFAULT 'moderator',
permissions JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 添加用户 ID=65 为管理员
INSERT INTO community_admins (user_id, role, permissions)
VALUES (65, 'admin', JSON_OBJECT(
'manage_channels', true,
'manage_messages', true,
'manage_users', true,
'manage_posts', true,
'pin_messages', true,
'delete_messages', true,
'announce', true,
'ban_users', true
))
ON DUPLICATE KEY UPDATE
role = 'admin',
permissions = JSON_OBJECT(
'manage_channels', true,
'manage_messages', true,
'manage_users', true,
'manage_posts', true,
'pin_messages', true,
'delete_messages', true,
'announce', true,
'ban_users', true
);
-- 3. 查看管理员列表
SELECT ca.*, u.username
FROM community_admins ca
LEFT JOIN users u ON ca.user_id = u.id;
-- 管理员权限说明:
-- admin (管理员): 拥有所有权限
-- - manage_channels: 创建/编辑/删除频道
-- - manage_messages: 编辑/删除任何消息
-- - manage_users: 踢出/禁言用户
-- - manage_posts: 编辑/删除任何帖子
-- - pin_messages: 置顶消息
-- - delete_messages: 删除消息
-- - announce: 在公告频道发布消息
-- - ban_users: 封禁用户
-- moderator (版主): 拥有部分权限
-- - pin_messages: 置顶消息
-- - delete_messages: 删除消息
-- - manage_posts: 管理帖子(锁定/置顶)

View File

@@ -75,6 +75,67 @@ def login_required(f):
return decorated_function
def get_user_admin_info(user_id):
"""获取用户管理员信息"""
try:
with get_db_engine().connect() as conn:
sql = text("""
SELECT role, permissions
FROM community_admins
WHERE user_id = :user_id
""")
result = conn.execute(sql, {'user_id': int(user_id)}).fetchone()
if result:
import json
permissions = result.permissions
if isinstance(permissions, str):
permissions = json.loads(permissions)
return {
'role': result.role,
'permissions': permissions or {},
'isAdmin': result.role == 'admin',
'isModerator': result.role in ['admin', 'moderator']
}
except Exception as e:
print(f"[Community API] 获取管理员信息失败: {e}")
return None
def check_permission(user_id, permission):
"""检查用户是否有指定权限"""
admin_info = get_user_admin_info(user_id)
if not admin_info:
return False
if admin_info['isAdmin']:
return True
return admin_info['permissions'].get(permission, False)
def admin_required(permission=None):
"""管理员权限验证装饰器"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({'code': 401, 'message': '请先登录'}), 401
g.current_user = user
# 检查权限
if permission:
if not check_permission(user['id'], permission):
return jsonify({'code': 403, 'message': '无权限执行此操作'}), 403
else:
admin_info = get_user_admin_info(user['id'])
if not admin_info:
return jsonify({'code': 403, 'message': '需要管理员权限'}), 403
g.admin_info = get_user_admin_info(user['id'])
return f(*args, **kwargs)
return decorated_function
return decorator
def api_response(data=None, message='success', code=200):
"""统一 API 响应格式"""
return jsonify({
@@ -92,6 +153,33 @@ def api_error(message, code=400):
}), code if code >= 400 else 400
# ============================================================
# 用户管理员状态 API
# ============================================================
@community_bp.route('/me/admin-status', methods=['GET'])
@login_required
def get_my_admin_status():
"""获取当前用户的管理员状态"""
user = g.current_user
admin_info = get_user_admin_info(user['id'])
if admin_info:
return api_response({
'isAdmin': admin_info['isAdmin'],
'isModerator': admin_info['isModerator'],
'role': admin_info['role'],
'permissions': admin_info['permissions']
})
else:
return api_response({
'isAdmin': False,
'isModerator': False,
'role': None,
'permissions': {}
})
# ============================================================
# 频道相关 API
# ============================================================

158
init_community_es.py Normal file
View File

@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""
初始化社区 ES 索引
运行: python init_community_es.py
"""
from elasticsearch import Elasticsearch
# ES 客户端
es_client = Elasticsearch(
hosts=["http://222.128.1.157:19200"],
request_timeout=30
)
# 索引配置
INDICES = {
'community_messages': {
'mappings': {
'properties': {
'id': {'type': 'keyword'},
'channel_id': {'type': 'keyword'},
'thread_id': {'type': 'keyword'},
'author_id': {'type': 'keyword'},
'author_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword'}}},
'author_avatar': {'type': 'keyword'},
'content': {'type': 'text', 'analyzer': 'ik_max_word'},
'type': {'type': 'keyword'},
'mentioned_users': {'type': 'keyword'},
'mentioned_stocks': {'type': 'keyword'},
'mentioned_everyone': {'type': 'boolean'},
'reply_to': {
'type': 'object',
'properties': {
'messageId': {'type': 'keyword'},
'authorId': {'type': 'keyword'},
'authorName': {'type': 'text'},
'contentPreview': {'type': 'text'}
}
},
'reactions': {'type': 'object', 'enabled': False},
'reaction_count': {'type': 'integer'},
'is_pinned': {'type': 'boolean'},
'is_edited': {'type': 'boolean'},
'is_deleted': {'type': 'boolean'},
'created_at': {'type': 'date'},
'edited_at': {'type': 'date'}
}
},
'settings': {
'number_of_shards': 1,
'number_of_replicas': 0
}
},
'community_forum_posts': {
'mappings': {
'properties': {
'id': {'type': 'keyword'},
'channel_id': {'type': 'keyword'},
'author_id': {'type': 'keyword'},
'author_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword'}}},
'author_avatar': {'type': 'keyword'},
'title': {'type': 'text', 'analyzer': 'ik_max_word'},
'content': {'type': 'text', 'analyzer': 'ik_max_word'},
'tags': {'type': 'keyword'},
'stock_symbols': {'type': 'keyword'},
'is_pinned': {'type': 'boolean'},
'is_locked': {'type': 'boolean'},
'is_deleted': {'type': 'boolean'},
'reply_count': {'type': 'integer'},
'view_count': {'type': 'integer'},
'like_count': {'type': 'integer'},
'last_reply_at': {'type': 'date'},
'last_reply_by': {'type': 'keyword'},
'created_at': {'type': 'date'},
'updated_at': {'type': 'date'}
}
},
'settings': {
'number_of_shards': 1,
'number_of_replicas': 0
}
},
'community_forum_replies': {
'mappings': {
'properties': {
'id': {'type': 'keyword'},
'post_id': {'type': 'keyword'},
'channel_id': {'type': 'keyword'},
'author_id': {'type': 'keyword'},
'author_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword'}}},
'author_avatar': {'type': 'keyword'},
'content': {'type': 'text', 'analyzer': 'ik_max_word'},
'reply_to': {'type': 'object', 'enabled': False},
'reactions': {'type': 'object', 'enabled': False},
'like_count': {'type': 'integer'},
'is_solution': {'type': 'boolean'},
'is_deleted': {'type': 'boolean'},
'created_at': {'type': 'date'},
'edited_at': {'type': 'date'}
}
},
'settings': {
'number_of_shards': 1,
'number_of_replicas': 0
}
}
}
def create_index(index_name, config):
"""创建索引"""
if es_client.indices.exists(index=index_name):
print(f"✓ 索引 {index_name} 已存在")
return True
try:
es_client.indices.create(index=index_name, body=config)
print(f"✓ 创建索引 {index_name} 成功")
return True
except Exception as e:
print(f"✗ 创建索引 {index_name} 失败: {e}")
return False
def check_es_connection():
"""检查 ES 连接"""
try:
info = es_client.info()
print(f"✓ ES 连接成功: {info['cluster_name']} (v{info['version']['number']})")
return True
except Exception as e:
print(f"✗ ES 连接失败: {e}")
return False
def main():
print("=" * 50)
print("初始化社区 ES 索引")
print("=" * 50)
# 检查连接
if not check_es_connection():
return
print()
# 创建索引
for index_name, config in INDICES.items():
create_index(index_name, config)
print()
print("=" * 50)
print("初始化完成")
print("=" * 50)
if __name__ == '__main__':
main()

View File

@@ -10,7 +10,6 @@ import {
Avatar,
IconButton,
HStack,
useColorModeValue,
Tooltip,
Menu,
MenuButton,
@@ -50,12 +49,12 @@ const MessageItem: React.FC<MessageItemProps> = ({
}) => {
const [isHovered, setIsHovered] = useState(false);
// 颜色
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const textColor = useColorModeValue('gray.800', 'gray.100');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const mentionBg = useColorModeValue('blue.50', 'blue.900');
const replyBg = useColorModeValue('gray.100', 'gray.700');
// 深色主题颜色HeroUI 风格)
const hoverBg = 'rgba(255, 255, 255, 0.05)';
const textColor = 'gray.100';
const mutedColor = 'gray.400';
const mentionBg = 'rgba(59, 130, 246, 0.2)';
const replyBg = 'rgba(255, 255, 255, 0.05)';
// 格式化时间
const formatTime = (dateStr: string) => {
@@ -246,9 +245,11 @@ const MessageItem: React.FC<MessageItemProps> = ({
position="absolute"
top="-10px"
right="8px"
bg={useColorModeValue('white', 'gray.700')}
borderRadius="md"
boxShadow="md"
bg="rgba(17, 24, 39, 0.95)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
p={1}
spacing={0}
>
@@ -258,6 +259,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
icon={<MdEmojiEmotions />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'yellow.400' }}
/>
</Tooltip>
<Tooltip label="回复">
@@ -266,6 +269,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
icon={<MdReply />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'purple.400' }}
onClick={() => onReply(message)}
/>
</Tooltip>
@@ -275,6 +280,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
icon={<MdForum />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'blue.400' }}
onClick={() => onThreadOpen?.(message.id)}
/>
</Tooltip>
@@ -285,11 +292,17 @@ const MessageItem: React.FC<MessageItemProps> = ({
icon={<MdMoreVert />}
size="sm"
variant="ghost"
color="gray.400"
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
/>
<MenuList>
<MenuItem icon={<MdPushPin />}></MenuItem>
<MenuItem icon={<MdEdit />}></MenuItem>
<MenuItem icon={<MdDelete />} color="red.500"></MenuItem>
<MenuList
bg="rgba(17, 24, 39, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 10px 40px rgba(0, 0, 0, 0.4)"
>
<MenuItem icon={<MdPushPin />} bg="transparent" _hover={{ bg: 'whiteAlpha.100' }} color="gray.300"></MenuItem>
<MenuItem icon={<MdEdit />} bg="transparent" _hover={{ bg: 'whiteAlpha.100' }} color="gray.300"></MenuItem>
<MenuItem icon={<MdDelete />} bg="transparent" _hover={{ bg: 'rgba(239, 68, 68, 0.2)' }} color="red.400"></MenuItem>
</MenuList>
</Menu>
</HStack>

View File

@@ -7,7 +7,6 @@ import {
Box,
Flex,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
import { zhCN } from 'date-fns/locale';
@@ -31,9 +30,10 @@ const MessageList: React.FC<MessageListProps> = ({
onReply,
onThreadOpen,
}) => {
const dividerColor = useColorModeValue('gray.300', 'gray.600');
const dividerTextColor = useColorModeValue('gray.500', 'gray.400');
const dividerBg = useColorModeValue('white', 'gray.800');
// 深色主题颜色HeroUI 风格)
const dividerColor = 'rgba(255, 255, 255, 0.1)';
const dividerTextColor = 'gray.400';
const dividerBg = 'rgba(15, 23, 42, 0.9)';
// 按日期分组消息
const messageGroups = useMemo(() => {