个股论坛重做
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
|
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):
|
def api_response(data=None, message='success', code=200):
|
||||||
"""统一 API 响应格式"""
|
"""统一 API 响应格式"""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -92,6 +153,33 @@ def api_error(message, code=400):
|
|||||||
}), code if code >= 400 else 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
|
# 频道相关 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,
|
Avatar,
|
||||||
IconButton,
|
IconButton,
|
||||||
HStack,
|
HStack,
|
||||||
useColorModeValue,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Menu,
|
Menu,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
@@ -50,12 +49,12 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
// 颜色
|
// 深色主题颜色(HeroUI 风格)
|
||||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
const hoverBg = 'rgba(255, 255, 255, 0.05)';
|
||||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
const textColor = 'gray.100';
|
||||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
const mutedColor = 'gray.400';
|
||||||
const mentionBg = useColorModeValue('blue.50', 'blue.900');
|
const mentionBg = 'rgba(59, 130, 246, 0.2)';
|
||||||
const replyBg = useColorModeValue('gray.100', 'gray.700');
|
const replyBg = 'rgba(255, 255, 255, 0.05)';
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (dateStr: string) => {
|
const formatTime = (dateStr: string) => {
|
||||||
@@ -246,9 +245,11 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
top="-10px"
|
top="-10px"
|
||||||
right="8px"
|
right="8px"
|
||||||
bg={useColorModeValue('white', 'gray.700')}
|
bg="rgba(17, 24, 39, 0.95)"
|
||||||
borderRadius="md"
|
border="1px solid"
|
||||||
boxShadow="md"
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
|
||||||
p={1}
|
p={1}
|
||||||
spacing={0}
|
spacing={0}
|
||||||
>
|
>
|
||||||
@@ -258,6 +259,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
icon={<MdEmojiEmotions />}
|
icon={<MdEmojiEmotions />}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: 'whiteAlpha.100', color: 'yellow.400' }}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="回复">
|
<Tooltip label="回复">
|
||||||
@@ -266,6 +269,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
icon={<MdReply />}
|
icon={<MdReply />}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: 'whiteAlpha.100', color: 'purple.400' }}
|
||||||
onClick={() => onReply(message)}
|
onClick={() => onReply(message)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -275,6 +280,8 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
icon={<MdForum />}
|
icon={<MdForum />}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: 'whiteAlpha.100', color: 'blue.400' }}
|
||||||
onClick={() => onThreadOpen?.(message.id)}
|
onClick={() => onThreadOpen?.(message.id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -285,11 +292,17 @@ const MessageItem: React.FC<MessageItemProps> = ({
|
|||||||
icon={<MdMoreVert />}
|
icon={<MdMoreVert />}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: 'whiteAlpha.100', color: 'white' }}
|
||||||
/>
|
/>
|
||||||
<MenuList>
|
<MenuList
|
||||||
<MenuItem icon={<MdPushPin />}>置顶消息</MenuItem>
|
bg="rgba(17, 24, 39, 0.95)"
|
||||||
<MenuItem icon={<MdEdit />}>编辑</MenuItem>
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
<MenuItem icon={<MdDelete />} color="red.500">删除</MenuItem>
|
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>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
Text,
|
Text,
|
||||||
useColorModeValue,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
|
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
@@ -31,9 +30,10 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
onReply,
|
onReply,
|
||||||
onThreadOpen,
|
onThreadOpen,
|
||||||
}) => {
|
}) => {
|
||||||
const dividerColor = useColorModeValue('gray.300', 'gray.600');
|
// 深色主题颜色(HeroUI 风格)
|
||||||
const dividerTextColor = useColorModeValue('gray.500', 'gray.400');
|
const dividerColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
const dividerBg = useColorModeValue('white', 'gray.800');
|
const dividerTextColor = 'gray.400';
|
||||||
|
const dividerBg = 'rgba(15, 23, 42, 0.9)';
|
||||||
|
|
||||||
// 按日期分组消息
|
// 按日期分组消息
|
||||||
const messageGroups = useMemo(() => {
|
const messageGroups = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user