update:1.更新用户管理-支持管理员新增用户

This commit is contained in:
xiamuceer
2025-11-13 11:43:45 +08:00
parent cb57c21569
commit 4516a2bcf7
10 changed files with 1266 additions and 261 deletions
+1 -1
View File
@@ -341,7 +341,7 @@ MuMuAINovel/
## 📧 联系方式
- 提交 [Issue](https://github.com/xiamuceer-j/MuMuAINovel/issues)
- Linux DO [讨论](https://linux.do/t/topic/1100112)
- Linux DO [讨论](https://linux.do/t/topic/1106333)
---
+393
View File
@@ -0,0 +1,393 @@
"""
管理员API - 用户管理功能
"""
from fastapi import APIRouter, HTTPException, Request, Depends
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
import hashlib
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, init_db
from app.models.user import User
from app.user_manager import user_manager
from app.user_password import password_manager
from app.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/admin", tags=["管理员"])
# ==================== 请求/响应模型 ====================
class CreateUserRequest(BaseModel):
"""创建用户请求"""
username: str = Field(..., min_length=3, max_length=20, description="用户名")
display_name: str = Field(..., min_length=2, max_length=50, description="显示名称")
password: Optional[str] = Field(None, min_length=6, description="初始密码,留空则自动生成")
avatar_url: Optional[str] = Field(None, description="头像URL")
trust_level: int = Field(0, ge=0, le=9, description="信任等级")
is_admin: bool = Field(False, description="是否为管理员")
class UpdateUserRequest(BaseModel):
"""更新用户请求"""
display_name: Optional[str] = Field(None, min_length=2, max_length=50)
avatar_url: Optional[str] = None
trust_level: Optional[int] = Field(None, ge=-1, le=9)
is_admin: Optional[bool] = Field(None, description="是否为管理员")
class ToggleStatusRequest(BaseModel):
"""切换用户状态请求"""
is_active: bool = Field(..., description="true=启用, false=禁用")
class ResetPasswordRequest(BaseModel):
"""重置密码请求"""
new_password: Optional[str] = Field(None, min_length=6, description="新密码,留空则重置为默认密码")
class UserResponse(BaseModel):
"""用户响应"""
user_id: str
username: str
display_name: str
avatar_url: Optional[str]
trust_level: int
is_admin: bool
is_active: bool
linuxdo_id: str
created_at: str
last_login: Optional[str]
class CreateUserResponse(BaseModel):
"""创建用户响应"""
success: bool
message: str
user: dict
default_password: Optional[str] = None
# ==================== 权限检查依赖 ====================
async def check_admin(request: Request) -> User:
"""检查管理员权限"""
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if not user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
# ==================== API 端点 ====================
@router.get("/users")
async def get_users(
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
try:
all_users = await user_manager.get_all_users()
users_data = []
for user in all_users:
# user_manager 返回的是 Pydantic User 对象,直接转为 dict
user_dict = user.model_dump()
user_dict["is_active"] = user.trust_level != -1
users_data.append(user_dict)
logger.info(f"管理员 {admin.user_id} 获取用户列表,共 {len(users_data)} 个用户")
return {
"total": len(users_data),
"users": users_data
}
except Exception as e:
logger.error(f"获取用户列表失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取用户列表失败: {str(e)}")
@router.post("/users")
async def create_user(
data: CreateUserRequest,
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""添加用户(仅管理员)"""
try:
# 检查用户名是否已存在
all_users = await user_manager.get_all_users()
for user in all_users:
if user.username == data.username:
raise HTTPException(status_code=409, detail="用户名已存在")
# 生成用户ID
user_id = f"admin_created_{hashlib.md5(data.username.encode()).hexdigest()[:16]}"
# 创建用户
new_user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_id,
username=data.username,
display_name=data.display_name,
avatar_url=data.avatar_url,
trust_level=data.trust_level
)
# 设置管理员标志
if data.is_admin:
# 直接更新数据库中的is_admin字段
async with await user_manager._get_session() as session:
result = await session.execute(
select(User).where(User.user_id == user_id)
)
db_user = result.scalar_one_or_none()
if db_user:
db_user.is_admin = True
await session.commit()
new_user.is_admin = True
# 设置密码
actual_password = await password_manager.set_password(
user_id=new_user.user_id,
username=data.username,
password=data.password
)
# 初始化用户数据库
try:
await init_db(new_user.user_id)
logger.info(f"用户 {new_user.user_id} 数据库初始化成功")
except Exception as e:
logger.error(f"用户 {new_user.user_id} 数据库初始化失败: {e}")
logger.info(f"管理员 {admin.user_id} 创建了新用户 {new_user.user_id} ({data.username})")
return CreateUserResponse(
success=True,
message="用户创建成功",
user=new_user.model_dump(),
default_password=actual_password if not data.password else None
)
except HTTPException:
raise
except Exception as e:
logger.error(f"创建用户失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
@router.put("/users/{user_id}")
async def update_user(
user_id: str,
data: UpdateUserRequest,
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""编辑用户信息(仅管理员)"""
try:
# 获取目标用户
target_user = await user_manager.get_user(user_id)
if not target_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新用户信息
async with await user_manager._get_session() as session:
result = await session.execute(
select(User).where(User.user_id == user_id)
)
db_user = result.scalar_one_or_none()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新字段
if data.display_name is not None:
db_user.display_name = data.display_name
if data.avatar_url is not None:
db_user.avatar_url = data.avatar_url
if data.trust_level is not None:
db_user.trust_level = data.trust_level
if data.is_admin is not None:
# 检查是否是最后一个管理员
if db_user.is_admin and not data.is_admin:
all_users = await user_manager.get_all_users()
admin_count = sum(1 for u in all_users if u.is_admin)
if admin_count <= 1:
raise HTTPException(status_code=400, detail="不能取消最后一个管理员的权限")
db_user.is_admin = data.is_admin
await session.commit()
await session.refresh(db_user)
logger.info(f"管理员 {admin.user_id} 更新了用户 {user_id} 的信息")
updated_user = await user_manager.get_user(user_id)
user_dict = updated_user.model_dump()
user_dict["is_active"] = updated_user.trust_level != -1
return {
"success": True,
"message": "用户信息更新成功",
"user": user_dict
}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新用户失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"更新用户失败: {str(e)}")
@router.post("/users/{user_id}/toggle-status")
async def toggle_user_status(
user_id: str,
data: ToggleStatusRequest,
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""切换用户状态(启用/禁用)(仅管理员)"""
try:
# 不允许禁用自己
if user_id == admin.user_id:
raise HTTPException(status_code=400, detail="不能禁用自己的账号")
# 获取目标用户
target_user = await user_manager.get_user(user_id)
if not target_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新状态
async with await user_manager._get_session() as session:
result = await session.execute(
select(User).where(User.user_id == user_id)
)
db_user = result.scalar_one_or_none()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if data.is_active:
# 启用用户:恢复trust_level为0(或之前的值)
db_user.trust_level = 0
else:
# 禁用用户:设置trust_level为-1
db_user.trust_level = -1
await session.commit()
status_text = "启用" if data.is_active else "禁用"
logger.info(f"管理员 {admin.user_id} {status_text}了用户 {user_id}")
return {
"success": True,
"message": f"用户已{status_text}",
"is_active": data.is_active
}
except HTTPException:
raise
except Exception as e:
logger.error(f"切换用户状态失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"切换用户状态失败: {str(e)}")
@router.post("/users/{user_id}/reset-password")
async def reset_password(
user_id: str,
data: ResetPasswordRequest,
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""重置用户密码(仅管理员)"""
try:
# 获取目标用户
target_user = await user_manager.get_user(user_id)
if not target_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 重置密码
actual_password = await password_manager.set_password(
user_id=user_id,
username=target_user.username,
password=data.new_password
)
logger.info(f"管理员 {admin.user_id} 重置了用户 {user_id} 的密码")
return {
"success": True,
"message": "密码重置成功",
"new_password": actual_password
}
except HTTPException:
raise
except Exception as e:
logger.error(f"重置密码失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"重置密码失败: {str(e)}")
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
admin: User = Depends(check_admin),
db: AsyncSession = Depends(get_db)
):
"""删除用户(仅管理员,慎用)"""
try:
# 不允许删除自己
if user_id == admin.user_id:
raise HTTPException(status_code=400, detail="不能删除自己的账号")
# 获取目标用户
target_user = await user_manager.get_user(user_id)
if not target_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查是否是最后一个管理员
if target_user.is_admin:
all_users = await user_manager.get_all_users()
admin_count = sum(1 for u in all_users if u.is_admin)
if admin_count <= 1:
raise HTTPException(status_code=400, detail="不能删除最后一个管理员账号")
# 删除用户(包括密码记录)
async with await user_manager._get_session() as session:
# 删除用户记录
result = await session.execute(
select(User).where(User.user_id == user_id)
)
db_user = result.scalar_one_or_none()
if db_user:
await session.delete(db_user)
# 删除密码记录
from app.models.user import UserPassword
result = await session.execute(
select(UserPassword).where(UserPassword.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
if pwd_record:
await session.delete(pwd_record)
await session.commit()
logger.warning(f"管理员 {admin.user_id} 删除了用户 {user_id}")
return {
"success": True,
"message": "用户已删除"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"删除用户失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"删除用户失败: {str(e)}")
+2 -1
View File
@@ -135,12 +135,13 @@ from app.api import (
projects, outlines, characters, chapters,
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories,
mcp_plugins
mcp_plugins, admin
)
app.include_router(auth.router, prefix="/api")
app.include_router(users.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(projects.router, prefix="/api")
app.include_router(wizard_stream.router, prefix="/api")
+16 -4
View File
@@ -1,9 +1,12 @@
"""
认证中间件 - 从 Cookie 中提取用户信息并注入到 request.state
"""
from fastapi import Request
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from app.user_manager import user_manager
from app.logger import get_logger
logger = get_logger(__name__)
class AuthMiddleware(BaseHTTPMiddleware):
@@ -20,9 +23,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
if user_id:
user = await user_manager.get_user(user_id)
if user:
request.state.user_id = user_id
request.state.user = user
request.state.is_admin = user.is_admin
# 检查用户是否被禁用 (trust_level = -1)
if user.trust_level == -1:
logger.warning(f"禁用用户尝试访问: {user_id} ({user.username})")
# 清除用户状态,视为未登录
request.state.user_id = None
request.state.user = None
request.state.is_admin = False
else:
# 用户正常,注入状态
request.state.user_id = user_id
request.state.user = user
request.state.is_admin = user.is_admin
else:
# 用户不存在,清除状态
request.state.user_id = None
+3
View File
@@ -164,6 +164,9 @@ class MCPTestService:
# 转换为OpenAI Function Calling格式
openai_tools = self._convert_tools_to_openai_format(tools)
logger.info(f"📋 转换后的OpenAI工具数量: {len(openai_tools)}")
logger.debug(f"📋 OpenAI工具列表: {[t['function']['name'] for t in openai_tools]}")
# 调用AI选择工具
prompt = f"""你是MCP插件测试助手,需要测试插件 '{plugin.plugin_name}' 的功能。
+2
View File
@@ -15,6 +15,7 @@ import ChapterAnalysis from './pages/ChapterAnalysis';
import WritingStyles from './pages/WritingStyles';
import Settings from './pages/Settings';
import MCPPlugins from './pages/MCPPlugins';
import UserManagement from './pages/UserManagement';
// import Polish from './pages/Polish';
import Login from './pages/Login';
import AuthCallback from './pages/AuthCallback';
@@ -38,6 +39,7 @@ function App() {
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/mcp-plugins" element={<ProtectedRoute><MCPPlugins /></ProtectedRoute>} />
<Route path="/user-management" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
<Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} />
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
<Route index element={<Navigate to="world-setting" replace />} />
+18 -255
View File
@@ -1,20 +1,17 @@
import { useState, useEffect } from 'react';
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination, Form, Input } from 'antd';
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined, LockOutlined, KeyOutlined } from '@ant-design/icons';
import { authApi, userApi } from '../services/api';
import { Dropdown, Avatar, Space, Typography, message, Modal, Form, Input, Button } from 'antd';
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined, LockOutlined } from '@ant-design/icons';
import { authApi } from '../services/api';
import type { User } from '../types';
import type { MenuProps } from 'antd';
import { useNavigate } from 'react-router-dom';
const { Text } = Typography;
export default function UserMenu() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [changePasswordForm] = Form.useForm();
const [changingPassword, setChangingPassword] = useState(false);
@@ -42,98 +39,12 @@ export default function UserMenu() {
}
};
const handleShowUserManagement = async () => {
const handleShowUserManagement = () => {
if (!currentUser?.is_admin) {
message.warning('只有管理员可以访问用户管理');
return;
}
setShowUserManagement(true);
loadUsers();
};
const loadUsers = async () => {
try {
setLoading(true);
const userList = await userApi.listUsers();
setUsers(userList);
} catch (error) {
console.error('获取用户列表失败:', error);
message.error('获取用户列表失败');
} finally {
setLoading(false);
}
};
const handleSetAdmin = async (userId: string, isAdmin: boolean) => {
try {
await userApi.setAdmin(userId, isAdmin);
message.success(isAdmin ? '已设置为管理员' : '已取消管理员权限');
loadUsers();
} catch (error) {
console.error('设置管理员失败:', error);
message.error('设置管理员失败');
}
};
const handleDeleteUser = async (userId: string) => {
try {
await userApi.deleteUser(userId);
message.success('用户已删除');
loadUsers();
} catch (error) {
console.error('删除用户失败:', error);
message.error('删除用户失败');
}
};
const handleResetPassword = async (userId: string, username: string) => {
Modal.confirm({
title: '重置用户密码',
content: (
<div>
<p> <strong>{username}</strong> </p>
<p style={{ color: '#8c8c8c', fontSize: 12 }}><strong>{username}@666</strong></p>
</div>
),
okText: '确定重置',
cancelText: '取消',
onOk: async () => {
try {
const result = await userApi.resetPassword(userId);
if (result.default_password) {
Modal.success({
title: '密码重置成功',
content: (
<div>
<p> <strong>{result.username}</strong> </p>
<p style={{
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: 4,
fontSize: 16,
fontFamily: 'monospace',
color: '#1890ff',
fontWeight: 'bold'
}}>
{result.default_password}
</p>
<p style={{ color: '#ff4d4f', fontSize: 12, marginTop: 8 }}>
</p>
</div>
),
});
} else {
message.success('密码重置成功');
}
loadUsers();
} catch (error: any) {
console.error('重置密码失败:', error);
message.error(error.response?.data?.detail || '重置密码失败');
}
},
});
navigate('/user-management');
};
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
@@ -169,14 +80,17 @@ export default function UserMenu() {
{
type: 'divider',
},
...(currentUser?.is_admin ? [{
key: 'user-management',
icon: <TeamOutlined />,
label: '用户管理',
onClick: handleShowUserManagement,
}, {
type: 'divider' as const,
}] : []),
...(currentUser?.is_admin ? [
{
key: 'user-management',
icon: <TeamOutlined />,
label: '用户管理',
onClick: handleShowUserManagement,
},
{
type: 'divider' as const,
}
] : []),
{
key: 'change-password',
icon: <LockOutlined />,
@@ -194,95 +108,6 @@ export default function UserMenu() {
},
];
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (text: string, record: User) => (
<Space>
<Avatar src={record.avatar_url} icon={<UserOutlined />} size="small" />
<div>
<div>{record.display_name || text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{text}</Text>
</div>
</Space>
),
},
{
title: 'Trust Level',
dataIndex: 'trust_level',
key: 'trust_level',
width: 120,
render: (level: number) => <Tag color="blue">{level}</Tag>,
},
{
title: '角色',
dataIndex: 'is_admin',
key: 'is_admin',
width: 100,
render: (isAdmin: boolean) => (
isAdmin ? <Tag color="gold" icon={<CrownOutlined />}></Tag> : <Tag></Tag>
),
},
{
title: '最后登录',
dataIndex: 'last_login',
key: 'last_login',
width: 180,
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 280,
render: (_: unknown, record: User) => {
const isSelf = record.user_id === currentUser?.user_id;
return (
<Space size="small">
{record.is_admin ? (
<Popconfirm
title="确定要取消管理员权限吗?"
onConfirm={() => handleSetAdmin(record.user_id, false)}
disabled={isSelf}
>
<Button size="small" disabled={isSelf}>
</Button>
</Popconfirm>
) : (
<Button
size="small"
type="primary"
onClick={() => handleSetAdmin(record.user_id, true)}
>
</Button>
)}
<Button
size="small"
icon={<KeyOutlined />}
onClick={() => handleResetPassword(record.user_id, record.username)}
disabled={isSelf}
title={isSelf ? '不能重置自己的密码' : '重置为默认密码'}
>
</Button>
<Popconfirm
title="确定要删除该用户吗?此操作不可恢复!"
onConfirm={() => handleDeleteUser(record.user_id)}
disabled={isSelf}
>
<Button size="small" danger disabled={isSelf}>
</Button>
</Popconfirm>
</Space>
);
},
},
];
if (!currentUser) {
return null;
}
@@ -365,68 +190,6 @@ export default function UserMenu() {
</div>
</Dropdown>
<Modal
title="用户管理"
open={showUserManagement}
onCancel={() => setShowUserManagement(false)}
footer={null}
width={900}
centered
styles={{
body: {
padding: 0,
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 380px)',
}
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}>
<div style={{
flex: 1,
overflow: 'hidden',
padding: '0 12px',
display: 'flex',
flexDirection: 'column',
}}>
<Table
columns={columns}
dataSource={users.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
rowKey="user_id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: 'calc(100vh - 520px)' }}
sticky
/>
</div>
<div style={{
padding: '16px 24px',
borderTop: '1px solid #f0f0f0',
background: '#fff',
display: 'flex',
justifyContent: 'center',
flexShrink: 0,
}}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={users.length}
showSizeChanger
showTotal={(total) => `${total} 个用户`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={(page, newPageSize) => {
setCurrentPage(page);
setPageSize(newPageSize);
}}
/>
</div>
</div>
</Modal>
<Modal
title="修改密码"
open={showChangePassword}
+6
View File
@@ -554,6 +554,12 @@ export default function MCPPluginsPage() {
checked={plugin.enabled}
onChange={(checked) => handleToggle(plugin, checked)}
size={isMobile ? 'small' : 'default'}
style={{
flexShrink: 0,
height: isMobile ? 16 : 22,
minHeight: isMobile ? 16 : 22,
lineHeight: isMobile ? '16px' : '22px'
}}
/>
</Tooltip>
<Tooltip title="测试连接">
+767
View File
@@ -0,0 +1,767 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Table,
Button,
Modal,
Form,
Input,
Switch,
Space,
Tag,
Popconfirm,
message,
Card,
Typography,
Badge,
InputNumber,
Tooltip,
Row,
Col,
Pagination,
Dropdown,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
KeyOutlined,
StopOutlined,
CheckCircleOutlined,
ArrowLeftOutlined,
TeamOutlined,
UserOutlined,
SearchOutlined,
MoreOutlined,
} from '@ant-design/icons';
import { adminApi } from '../services/api';
import type { User } from '../types';
import UserMenu from '../components/UserMenu';
const { Title, Text } = Typography;
interface UserWithStatus extends User {
is_active?: boolean;
}
export default function UserManagement() {
const navigate = useNavigate();
const [users, setUsers] = useState<UserWithStatus[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [resetPasswordModalVisible, setResetPasswordModalVisible] = useState(false);
const [currentUser, setCurrentUser] = useState<UserWithStatus | null>(null);
const [newPassword, setNewPassword] = useState('');
const [pageSize, setPageSize] = useState(20);
const [currentPage, setCurrentPage] = useState(1);
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
const [editForm] = Form.useForm();
// 过滤用户列表
const filteredUsers = users.filter(user => {
if (!searchText) return true;
const searchLower = searchText.toLowerCase();
return (
user.username?.toLowerCase().includes(searchLower) ||
user.display_name?.toLowerCase().includes(searchLower) ||
user.user_id?.toLowerCase().includes(searchLower)
);
});
// 加载用户列表
const loadUsers = async () => {
setLoading(true);
try {
const res = await adminApi.getUsers();
setUsers(res.users);
} catch (error) {
console.error('加载用户列表失败:', error);
message.error('加载用户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
// 添加用户
const handleCreate = async (values: any) => {
try {
const res = await adminApi.createUser(values);
message.success('用户创建成功');
// 如果有默认密码,显示给管理员
if (res.default_password) {
Modal.info({
title: '用户创建成功',
content: (
<div>
<p><Text strong>{values.username}</Text></p>
<p><Text strong copyable>{res.default_password}</Text></p>
<p style={{ color: '#ff4d4f', marginTop: 16 }}>
</p>
</div>
),
width: 500,
});
}
setModalVisible(false);
form.resetFields();
loadUsers();
} catch (error) {
console.error('创建用户失败:', error);
message.error('创建用户失败');
}
};
// 编辑用户
const handleEdit = (user: UserWithStatus) => {
setCurrentUser(user);
editForm.setFieldsValue({
display_name: user.display_name,
avatar_url: user.avatar_url,
trust_level: user.trust_level,
is_admin: user.is_admin,
});
setEditModalVisible(true);
};
const handleUpdate = async (values: any) => {
if (!currentUser) return;
try {
await adminApi.updateUser(currentUser.user_id, values);
message.success('用户信息更新成功');
setEditModalVisible(false);
editForm.resetFields();
loadUsers();
} catch (error) {
console.error('更新用户失败:', error);
message.error('更新用户失败');
}
};
// 切换用户状态
const handleToggleStatus = async (user: UserWithStatus) => {
const isActive = user.is_active !== false;
const action = isActive ? '禁用' : '启用';
try {
await adminApi.toggleUserStatus(user.user_id, !isActive);
message.success(`用户已${action}`);
loadUsers();
} catch (error) {
console.error(`${action}用户失败:`, error);
message.error(`${action}用户失败`);
}
};
// 重置密码
const handleResetPassword = (user: UserWithStatus) => {
setCurrentUser(user);
setNewPassword('');
setResetPasswordModalVisible(true);
};
const handleResetPasswordConfirm = async () => {
if (!currentUser) return;
try {
const res = await adminApi.resetPassword(
currentUser.user_id,
newPassword || undefined
);
Modal.info({
title: '密码重置成功',
content: (
<div>
<p><Text strong>{currentUser.username}</Text></p>
<p><Text strong copyable>{res.new_password}</Text></p>
<p style={{ color: '#ff4d4f', marginTop: 16 }}>
</p>
</div>
),
width: 500,
});
setResetPasswordModalVisible(false);
setNewPassword('');
} catch (error) {
console.error('重置密码失败:', error);
message.error('重置密码失败');
}
};
// 删除用户
const handleDelete = async (user: UserWithStatus) => {
try {
await adminApi.deleteUser(user.user_id);
message.success('用户已删除');
loadUsers();
} catch (error) {
console.error('删除用户失败:', error);
message.error('删除用户失败');
}
};
const isMobile = window.innerWidth <= 768;
// 表格列定义
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 150,
render: (text: string) => (
<Space>
<UserOutlined style={{ color: '#1890ff' }} />
<Text strong>{text}</Text>
</Space>
),
},
{
title: '显示名称',
dataIndex: 'display_name',
key: 'display_name',
width: 150,
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
render: (isActive: boolean) => (
<Badge
status={isActive !== false ? 'success' : 'error'}
text={isActive !== false ? '正常' : '已禁用'}
/>
),
},
{
title: '角色',
dataIndex: 'is_admin',
key: 'is_admin',
width: 100,
render: (isAdmin: boolean) => (
<Tag color={isAdmin ? 'gold' : 'blue'}>
{isAdmin ? '👑 管理员' : '普通用户'}
</Tag>
),
},
{
title: '信任等级',
dataIndex: 'trust_level',
key: 'trust_level',
width: 100,
render: (level: number) => (
<Tag color={level === -1 ? 'default' : level >= 5 ? 'green' : 'blue'}>
{level === -1 ? '已禁用' : `Level ${level}`}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (date: string) => date ? new Date(date).toLocaleString('zh-CN') : '-',
},
{
title: '最后登录',
dataIndex: 'last_login',
key: 'last_login',
width: 180,
render: (date: string) => date ? new Date(date).toLocaleString('zh-CN') : '从未登录',
},
{
title: '操作',
key: 'action',
width: isMobile ? 80 : 300,
fixed: 'right' as const,
render: (_: any, record: UserWithStatus) => {
const isActive = record.is_active !== false;
// 移动端:使用下拉菜单
if (isMobile) {
const menuItems = [
{
key: 'edit',
label: '编辑用户',
icon: <EditOutlined />,
onClick: () => handleEdit(record),
},
{
key: 'reset',
label: '重置密码',
icon: <KeyOutlined />,
onClick: () => handleResetPassword(record),
},
{
key: 'toggle',
label: isActive ? '禁用用户' : '启用用户',
icon: isActive ? <StopOutlined /> : <CheckCircleOutlined />,
danger: isActive,
onClick: () => {
Modal.confirm({
title: `确定${isActive ? '禁用' : '启用'}该用户吗?`,
onOk: () => handleToggleStatus(record),
okText: '确定',
cancelText: '取消',
});
},
},
...(!record.is_admin ? [{
key: 'delete',
label: '删除用户',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确定删除该用户吗?此操作不可恢复!',
onOk: () => handleDelete(record),
okText: '确定',
cancelText: '取消',
okButtonProps: { danger: true },
});
},
}] : []),
];
return (
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} />
</Dropdown>
);
}
// 桌面端:保持原有按钮样式
return (
<Space size="small">
<Tooltip title="编辑用户">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
</Tooltip>
<Tooltip title="重置密码">
<Button
type="link"
size="small"
icon={<KeyOutlined />}
onClick={() => handleResetPassword(record)}
>
</Button>
</Tooltip>
<Popconfirm
title={`确定${isActive ? '禁用' : '启用'}该用户吗?`}
onConfirm={() => handleToggleStatus(record)}
okText="确定"
cancelText="取消"
>
<Tooltip title={isActive ? '禁用用户' : '启用用户'}>
<Button
type="link"
size="small"
danger={isActive}
icon={isActive ? <StopOutlined /> : <CheckCircleOutlined />}
>
{isActive ? '禁用' : '启用'}
</Button>
</Tooltip>
</Popconfirm>
{!record.is_admin && (
<Popconfirm
title="确定删除该用户吗?此操作不可恢复!"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除用户">
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Tooltip>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<div style={{
height: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: isMobile ? '20px 16px' : '40px 24px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<div style={{
maxWidth: 1400,
margin: '0 auto',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* 顶部导航卡片 */}
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
marginBottom: isMobile ? 20 : 24,
}}
>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col xs={24} sm={12}>
<Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0 }}>
<TeamOutlined style={{ color: '#fa8c16', marginRight: 8 }} />
</Title>
<Text type="secondary" style={{ fontSize: isMobile ? 12 : 14 }}>
</Text>
</Space>
</Col>
<Col xs={24} sm={12}>
<Space size={12} style={{ display: 'flex', justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setModalVisible(true)}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<UserMenu />
</Space>
</Col>
</Row>
</Card>
{/* 主内容卡片 */}
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
bodyStyle={{
padding: 0,
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 搜索栏 */}
<div style={{
padding: '16px 24px 0 24px',
borderBottom: '1px solid #f0f0f0',
}}>
<Input
placeholder="搜索用户名、显示名称或用户ID"
prefix={<SearchOutlined style={{ color: '#999' }} />}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1); // 搜索时重置到第一页
}}
allowClear
style={{
borderRadius: 8,
}}
/>
</div>
{/* 表格区域 */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '16px 24px 0 24px',
}}>
<Table
columns={columns}
dataSource={filteredUsers.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
rowKey="user_id"
loading={loading}
scroll={{
x: 1400,
y: 'calc(100vh - 410px)'
}}
pagination={false}
/>
</div>
{/* 固定分页控件 */}
<div style={{
padding: '16px 24px 24px 24px',
borderTop: '1px solid #f0f0f0',
background: 'rgba(255, 255, 255, 0.95)',
display: 'flex',
justifyContent: 'center',
}}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredUsers.length}
showSizeChanger
showTotal={(total) => `${total} 个用户${searchText ? ' (已过滤)' : ''}`}
pageSizeOptions={[20, 50, 100]}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
}}
onShowSizeChange={(_current, size) => {
setCurrentPage(1);
setPageSize(size);
}}
/>
</div>
</Card>
</div>
{/* 添加用户对话框 */}
<Modal
title={<span><PlusOutlined style={{ marginRight: 8 }} /></span>}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
form.resetFields();
}}
onOk={() => form.submit()}
width={isMobile ? '90%' : 600}
centered
okText="创建"
cancelText="取消"
>
<Form
form={form}
layout="vertical"
onFinish={handleCreate}
>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '用户名长度3-20位' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字和下划线' },
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="显示名称"
name="display_name"
rules={[
{ required: true, message: '请输入显示名称' },
{ min: 2, max: 50, message: '显示名称长度2-50位' },
]}
>
<Input placeholder="请输入显示名称" />
</Form.Item>
<Form.Item
label="初始密码"
name="password"
extra="留空则自动生成 username@666"
rules={[
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password placeholder="留空则自动生成" />
</Form.Item>
<Form.Item
label="头像URL"
name="avatar_url"
>
<Input placeholder="请输入头像URL(可选)" />
</Form.Item>
<Form.Item
label="信任等级"
name="trust_level"
initialValue={0}
>
<InputNumber min={0} max={9} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="设为管理员"
name="is_admin"
valuePropName="checked"
initialValue={false}
>
<Switch
size={isMobile ? 'small' : 'default'}
style={{
flexShrink: 0,
height: isMobile ? 16 : 22,
minHeight: isMobile ? 16 : 22,
lineHeight: isMobile ? '16px' : '22px'
}}
/>
</Form.Item>
</Form>
</Modal>
{/* 编辑用户对话框 */}
<Modal
title={<span><EditOutlined style={{ marginRight: 8 }} /></span>}
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
editForm.resetFields();
}}
onOk={() => editForm.submit()}
width={isMobile ? '90%' : 600}
centered
okText="保存"
cancelText="取消"
>
<Form
form={editForm}
layout="vertical"
onFinish={handleUpdate}
>
<Form.Item
label="显示名称"
name="display_name"
rules={[
{ required: true, message: '请输入显示名称' },
{ min: 2, max: 50, message: '显示名称长度2-50位' },
]}
>
<Input placeholder="请输入显示名称" />
</Form.Item>
<Form.Item
label="头像URL"
name="avatar_url"
>
<Input placeholder="请输入头像URL(可选)" />
</Form.Item>
<Form.Item
label="信任等级"
name="trust_level"
>
<InputNumber min={0} max={9} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="设为管理员"
name="is_admin"
valuePropName="checked"
>
<Switch
size={isMobile ? 'small' : 'default'}
style={{
flexShrink: 0,
height: isMobile ? 16 : 22,
minHeight: isMobile ? 16 : 22,
lineHeight: isMobile ? '16px' : '22px'
}}
/>
</Form.Item>
</Form>
</Modal>
{/* 重置密码对话框 */}
<Modal
title={<span><KeyOutlined style={{ marginRight: 8 }} /></span>}
open={resetPasswordModalVisible}
onCancel={() => {
setResetPasswordModalVisible(false);
setNewPassword('');
}}
onOk={handleResetPasswordConfirm}
width={isMobile ? '90%' : 500}
centered
okText="确认重置"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<Text><Text strong>{currentUser?.username}</Text></Text>
</div>
<Form layout="vertical">
<Form.Item
label="新密码"
extra="留空则重置为默认密码 username@666"
>
<Input.Password
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="留空则使用默认密码"
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+58
View File
@@ -520,4 +520,62 @@ export const mcpPluginApi = {
// 调用工具
callTool: (data: MCPToolCallRequest) =>
api.post<unknown, MCPToolCallResponse>('/mcp/call', data),
};
// 管理员API
export const adminApi = {
// 获取用户列表
getUsers: () =>
api.get<unknown, { total: number; users: User[] }>('/admin/users'),
// 添加用户
createUser: (data: {
username: string;
display_name: string;
password?: string;
avatar_url?: string;
trust_level?: number;
is_admin?: boolean;
}) =>
api.post<unknown, {
success: boolean;
message: string;
user: User;
default_password?: string;
}>('/admin/users', data),
// 编辑用户
updateUser: (userId: string, data: {
display_name?: string;
avatar_url?: string;
trust_level?: number;
}) =>
api.put<unknown, {
success: boolean;
message: string;
user: User;
}>(`/admin/users/${userId}`, data),
// 切换用户状态(启用/禁用)
toggleUserStatus: (userId: string, isActive: boolean) =>
api.post<unknown, {
success: boolean;
message: string;
is_active: boolean;
}>(`/admin/users/${userId}/toggle-status`, { is_active: isActive }),
// 重置密码
resetPassword: (userId: string, newPassword?: string) =>
api.post<unknown, {
success: boolean;
message: string;
new_password: string;
}>(`/admin/users/${userId}/reset-password`, { new_password: newPassword }),
// 删除用户
deleteUser: (userId: string) =>
api.delete<unknown, {
success: boolean;
message: string;
}>(`/admin/users/${userId}`),
};