个股论坛重做
This commit is contained in:
59
community_admin_setup.sql
Normal file
59
community_admin_setup.sql
Normal 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: 管理帖子(锁定/置顶)
|
||||
@@ -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
158
init_community_es.py
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user