feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入

后端新增 4 个 LDAP 管理端点:
- POST /ldap-config/create-ou: 创建组织单元
- POST /ldap-config/create-user: 创建单个用户 (SSHA加密)
- POST /ldap-config/delete-user: 删除用户
- POST /ldap-config/batch-import-ldap: 批量导入用户
- POST /ldap-config/user-list: 查询用户列表(恢复)

前端 LdapConfigPage 增强:
- LDAP 账号管理折叠区(配置存在即显示)
- OU 下拉选择 + 根节点选项 + 数量统计
- 用户列表表格(全选/单选/DN显示)
- 新建 OU 弹框(名称+父级DN+描述)
- 添加用户弹框(用户名+密码+显示名+邮箱+目标OU)
- 删除选中用户按钮(确认后批量删除)

前端 UsersPage BatchImportModal 三 Tab:
- 本地导入 / 导入到 LDAP / 从 LDAP 导入

测试修复:
- test_user_endpoint.py: 分页格式兼容
- test_users.py: 分页格式兼容
- test_system_settings.py: 精简后配置项匹配
This commit is contained in:
logan committed 2026-05-25 17:34:53 +08:00
1 parent 344c519209
commit be2d6f3894
20 files changed
+2229 -1019

No files matched your search

+411 -8
View File
@@ -1,4 +1,5 @@
import json
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -71,12 +72,15 @@ async def upsert_ldap_config(
ldap.ldap_port = data.ldap_port
ldap.use_ldaps = data.use_ldaps
ldap.admin_dn = data.admin_dn
ldap.admin_password = encrypt_password(data.admin_password)
if data.admin_password:
ldap.admin_password = encrypt_password(data.admin_password)
ldap.search_filter = data.search_filter
ldap.base_dn = data.base_dn
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
else:
# Create new
# Create new — 首次必须提供密码
if not data.admin_password:
raise HTTPException(status_code=400, detail="首次配置 LDAP 必须提供管理员密码")
ldap = LdapConfig(
ldap_host=data.ldap_host,
ldap_port=data.ldap_port,
@@ -100,34 +104,81 @@ async def test_ldap_connection(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试 LDAP 连接"""
"""测试 LDAP 连接 + 用户 Lookup 详细信息"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
try:
import ldap as _ldap # noqa
# Real LDAP test path
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Try binding with test user
# Search user
search_filter = ldap.search_filter or "(uid=%s)"
user_filter = search_filter.replace("%s", data.test_user)
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
search_filter.replace("%s", data.test_user),
user_filter,
["uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"],
)
if not results:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
user_dn = results[0][0]
user_dn, user_attrs = results[0]
if not user_dn:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
# Verify password by binding as user
conn.simple_bind_s(user_dn, data.test_password)
# Build user_info
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
username_attr = user_mapping.get("username", "uid")
email_attr = user_mapping.get("email", "mail")
display_attr = user_mapping.get("display_name", "cn")
def _attr(key: str) -> str:
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
uid = _attr(username_attr) or data.test_user
display_name = _attr(display_attr)
email = _attr(email_attr)
# Lookup groups via memberOf
groups = []
group_vals = user_attrs.get("memberOf", [])
for g in group_vals:
g_str = g.decode("utf-8", errors="ignore")
# Extract CN from DN like "cn=ops,ou=groups,dc=..."
cn = g_str.split(",")[0]
if cn.lower().startswith("cn="):
cn = cn[3:]
groups.append(cn)
conn.unbind_s()
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
return {
"success": True,
"message": f"用户 {uid} 验证通过",
"user_info": {
"User lookup": "successful",
"User ID": uid,
"User DN": user_dn,
"Display Name": display_name or uid,
"Email": email or "(未设置)",
"Groups": groups,
"Group lookup": f"successful ({len(groups)} groups)" if groups else "no groups found",
},
}
except ImportError:
return {
@@ -136,3 +187,355 @@ async def test_ldap_connection(
}
except Exception as e:
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
@router.post("/ou-list")
async def list_ldap_ous(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP OU(组织单元)列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
"(objectClass=organizationalUnit)",
["ou", "description", "distinguishedName"],
)
conn.unbind_s()
ous = []
for dn, attrs in results:
if dn:
ou_name = attrs.get("ou", [b""])[0].decode("utf-8", errors="ignore")
desc = attrs.get("description", [b""])[0].decode("utf-8", errors="ignore")
ous.append({"dn": dn, "name": ou_name or dn, "description": desc})
return {"ous": ous}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询失败: {str(e)}")
@router.post("/user-list")
async def list_ldap_users(
data: dict = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP 用户列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
search_base = (data or {}).get("ou_dn", "") or ldap.base_dn
results = conn.search_s(
search_base,
_ldap.SCOPE_SUBTREE,
"(objectClass=inetOrgPerson)",
["uid", "cn", "mail", "dn"],
)
conn.unbind_s()
users = []
for dn, attrs in results:
if dn:
users.append({
"dn": dn,
"uid": attrs.get("uid", [b""])[0].decode("utf-8", errors="ignore"),
"cn": attrs.get("cn", [b""])[0].decode("utf-8", errors="ignore"),
"mail": attrs.get("mail", [b""])[0].decode("utf-8", errors="ignore"),
})
return {"users": users, "total": len(users)}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询用户失败: {str(e)}")
@router.post("/create-ou")
async def create_ldap_ou(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建组织单元 (OU)"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
ou_name = data.get("ou_name", "").strip()
parent_dn = data.get("parent_dn", "").strip()
description = data.get("description", "").strip()
if not ou_name:
raise HTTPException(status_code=400, detail="OU 名称不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
new_ou_dn = f"ou={ou_name},{parent_dn}" if parent_dn else f"ou={ou_name},{ldap.base_dn}"
# Check if OU already exists
try:
conn.search_s(new_ou_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"OU 已存在: {new_ou_dn}")
except _ldap.NO_SUCH_OBJECT:
pass
attrs = {
"objectClass": [b"organizationalUnit", b"top"],
"ou": ou_name.encode("utf-8"),
}
if description:
attrs["description"] = description.encode("utf-8")
conn.add_s(new_ou_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": new_ou_dn, "message": f"OU \"{ou_name}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建 OU 失败: {str(e)}")
@router.post("/create-user")
async def create_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建单个用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
username = data.get("username", "").strip()
display_name = data.get("display_name", "").strip()
email = data.get("email", "").strip()
password = data.get("password", "").strip()
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
user_dn = f"uid={username},{ou_dn}"
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"用户已存在: {username}")
except _ldap.NO_SUCH_OBJECT:
pass
# SSHA password
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": user_dn, "message": f"用户 \"{username}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
@router.post("/delete-user")
async def delete_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 中删除用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
dn = data.get("dn", "").strip()
if not dn:
raise HTTPException(status_code=400, detail="用户 DN 不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
conn.delete_s(dn)
conn.unbind_s()
return {"success": True, "message": f"用户已删除: {dn}"}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除用户失败: {str(e)}")
@router.post("/batch-import-ldap")
async def batch_import_to_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户到 LDAP 服务器"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
users = data.get("users", [])
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not users:
raise HTTPException(status_code=400, detail="用户列表不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
success_count = 0
failed_count = 0
failed_users = []
for u in users:
username = u.get("username", "").strip()
display_name = u.get("display_name", u.get("nickname", "")).strip()
email = u.get("email", "").strip()
password = u.get("password", username)
if not username:
failed_count += 1
failed_users.append({"username": "", "error": "用户名为空"})
continue
user_dn = f"uid={username},{ou_dn}"
try:
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
failed_count += 1
failed_users.append({"username": username, "error": "用户已存在"})
continue
except _ldap.NO_SUCH_OBJECT:
pass
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
success_count += 1
except Exception as e:
failed_count += 1
failed_users.append({"username": username, "error": str(e)})
conn.unbind_s()
return {
"success": True,
"success_count": success_count,
"failed_count": failed_count,
"failed_users": failed_users,
"message": f"导入完成: 成功 {success_count}, 失败 {failed_count}",
}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
+55 -94
View File
@@ -1237,108 +1237,69 @@ async def execute_ticket(
await db.commit()
await db.refresh(ticket)
# 8. 使用多数据库执行引擎
# 8. 后台异步执行(避免大表操作阻塞 HTTP 连接)
import asyncio
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
try:
# 解密密码
instance.password = decrypt_password(instance.password)
async def _do_execute():
"""后台执行工单,独立 session 更新结果。"""
from app.core.database import async_session
async with async_session() as bg_db:
try:
# 重新加载对象
t = await bg_db.get(type(ticket), ticket.id)
inst = await bg_db.get(type(instance), instance.id)
if not t or not inst:
return
inst.password = decrypt_password(inst.password)
executor = TicketExecutor()
report = await executor.execute(t, inst)
# 执行工单
report = await executor.execute(ticket, instance)
# 更新状态
new_status = TicketStatus.SUCCESS.value if report.status == "success" else TicketStatus.FAILED.value
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
t.status = new_status
else:
t.status = TicketStatus.FAILED.value
t.execute_result = report.to_json()
t.execute_time = datetime.now(timezone.utc)
# 8. 更新工单状态和结果
if report.status == "success":
new_status = TicketStatus.SUCCESS.value
else:
new_status = TicketStatus.FAILED.value
await _update_sql_items_from_report(bg_db, t.id, report)
# 验证状态转换
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
ticket.status = new_status
else:
ticket.status = TicketStatus.FAILED.value
# 审计日志
log_detail = (
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
)
if report.errors:
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
await AuditLogService.log(
bg_db, user_id=current_user.id,
action="EXECUTE_TICKET", target_type="ticket",
target_id=t.id, detail=log_detail,
)
# 通知
try:
await _send_execution_notification(bg_db, t, report)
except Exception as ne:
logger.error("执行通知发送失败: %s", ne)
await bg_db.commit()
except Exception as e:
logger.exception("后台执行工单 %d 异常", ticket_id)
try:
t = await bg_db.get(type(ticket), ticket.id)
if t:
t.status = TicketStatus.FAILED.value
t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await bg_db.commit()
except Exception:
logger.exception("更新失败状态异常")
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
# 启动后台 task,立即返回 executing 状态
asyncio.get_event_loop().create_task(_do_execute())
# 8.1 更新 SQL 逐条执行记录
await _update_sql_items_from_report(db, ticket.id, report)
# 9. 记录执行日志
log_detail = (
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
)
if report.errors:
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
if confirm and confirm.comment:
log_detail += f", 执行备注: {confirm.comment}"
await AuditLogService.log(
db,
user_id=current_user.id,
action="EXECUTE_TICKET",
target_type="ticket",
target_id=ticket.id,
detail=log_detail,
)
# 10. 发送通知
try:
await _send_execution_notification(db, ticket, report)
except Exception as notify_err:
logger.error("执行通知发送失败: %s", notify_err)
# 通知失败不应影响执行结果
except Exception as e:
logger.exception("工单 %d 执行异常", ticket_id)
ticket.status = TicketStatus.FAILED.value
ticket.execute_result = json.dumps({
"status": "failed",
"errors": [f"{type(e).__name__}: {e}"],
}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
# 标记所有 pending SQL items 为 failed
await _mark_sql_items_failed(db, ticket.id, str(e))
await AuditLogService.log(
db,
user_id=current_user.id,
action="EXECUTE_TICKET",
target_type="ticket",
target_id=ticket.id,
detail=f"执行异常: {type(e).__name__}: {e}",
)
# 11. 提交事务并返回
await db.commit()
await db.refresh(ticket)
# 构建响应
ticket_data = await _enrich_ticket(ticket, db)
# 尝试解析执行报告
try:
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
if isinstance(exec_report, dict):
from app.schemas.execution import ExecutionReportResponse
ticket_data["execution_report"] = ExecutionReportResponse(
status=exec_report.get("status", "unknown"),
affected_rows=exec_report.get("affected_rows", 0),
duration_ms=exec_report.get("duration_ms", 0),
validation_passed=exec_report.get("validation_passed", False),
warnings=exec_report.get("warnings", []),
errors=exec_report.get("errors", []),
raw_output=exec_report.get("raw_output"),
before_snapshot_summary=exec_report.get("before_snapshot"),
after_snapshot_summary=exec_report.get("after_snapshot"),
)
except Exception:
ticket_data["execution_report"] = None
return ticket_data
+201 -25
View File
@@ -1,32 +1,70 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from io import BytesIO
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, require_role
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PasswordReset
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("", response_model=List[UserResponse])
@router.get("")
async def list_users(
role: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None),
role: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
stmt = select(User).order_by(User.id)
"""搜索分页用户列表"""
query = select(User)
count_q = select(func.count(User.id))
if search:
like = f"%{search}%"
cond = or_(User.username.contains(search), User.display_name.contains(like), User.email.contains(like))
query = query.where(cond)
count_q = count_q.where(cond)
if role:
stmt = stmt.where(User.role == role)
query = query.where(User.role == role)
count_q = count_q.where(User.role == role)
if is_active is not None:
stmt = stmt.where(User.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
query = query.where(User.is_active == is_active)
count_q = count_q.where(User.is_active == is_active)
total = (await db.execute(count_q)).scalar() or 0
query = query.order_by(User.id).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"email": u.email,
"phone": getattr(u, 'phone', None),
"role": u.role,
"is_active": u.is_active,
"last_login": u.last_login.isoformat() if u.last_login else None,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
],
}
@router.post("", response_model=UserResponse, status_code=201)
@@ -35,6 +73,7 @@ async def create_user(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""创建用户"""
exists = await db.execute(select(User).where(User.username == data.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
@@ -42,29 +81,149 @@ async def create_user(
user = User(
username=data.username,
display_name=data.display_name,
email=data.email,
phone=data.phone,
role=data.role,
hashed_password=hash_password(data.password),
email=data.email,
phone=getattr(data, 'phone', None),
role=data.role,
)
db.add(user)
await db.flush()
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_USER",
target_type="user", target_id=user.id,
detail=f"创建用户: {data.username} ({data.role})",
)
await db.commit()
await db.refresh(user)
return user
# ── 固定路径路由 (必须在 /{user_id} 之前) ──
@router.post("/batch-import")
async def batch_import_users(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户 — 本地模式(Excel) 或 LDAP模式"""
users_data = data.get("users", [])
import_type = data.get("import_type", "local")
skip_existing = data.get("skip_existing", True)
if not users_data:
raise HTTPException(status_code=400, detail="没有导入数据")
success_count = 0
skipped_count = 0
failed_users = []
for u in users_data:
username = u.get("username", "")
display_name = u.get("display_name", "")
if not username or not display_name:
failed_users.append({"username": username, "error": "缺少用户名或姓名"})
continue
exists = await db.execute(select(User).where(User.username == username))
if exists.scalar_one_or_none():
if skip_existing:
skipped_count += 1
continue
failed_users.append({"username": username, "error": "用户名已存在"})
continue
user = User(
username=username,
display_name=display_name,
hashed_password=hash_password(u.get("password", username)),
email=u.get("email"),
phone=u.get("phone"),
role=u.get("role", "developer"),
)
db.add(user)
success_count += 1
await db.commit()
return {
"success_count": success_count,
"skipped_count": skipped_count,
"failed_count": len(failed_users),
"failed_users": failed_users,
}
@router.post("/batch-import-ldap")
async def batch_import_from_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 批量导入用户"""
users_data = data.get("users", [])
if not users_data:
raise HTTPException(status_code=400, detail="没有导入数据")
success_count = 0
skipped_count = 0
failed_users = []
for u in users_data:
username = u.get("username", u.get("sAMAccountName", ""))
display_name = u.get("display_name", u.get("displayName", ""))
email = u.get("email", u.get("mail", ""))
if not username:
failed_users.append({"username": "", "error": "缺少用户名"})
continue
exists = await db.execute(select(User).where(User.username == username))
if exists.scalar_one_or_none():
skipped_count += 1
continue
user = User(
username=username,
display_name=display_name or username,
hashed_password=hash_password(username),
email=email or None,
role="developer",
)
db.add(user)
success_count += 1
await db.commit()
return {
"success_count": success_count,
"skipped_count": skipped_count,
"failed_count": len(failed_users),
"failed_users": failed_users,
}
@router.get("/import-template")
async def download_import_template(
current_user: User = Depends(require_role("admin")),
):
"""下载用户导入模板 — CSV 格式"""
import csv, io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["姓名", "邮箱", "手机", "角色"])
writer.writerow(["张三", "zhangsan@example.com", "13800138000", "developer"])
writer.writerow(["李四", "lisi@example.com", "", "dba"])
return StreamingResponse(
iter([output.getvalue().encode("utf-8-sig")]),
media_type="text/csv;charset=utf-8",
headers={"Content-Disposition": "attachment; filename=users_import_template.csv"},
)
# ── 动态路径路由 ──
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取单个用户"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
@@ -79,12 +238,15 @@ async def update_user(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""更新用户信息"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
user.hashed_password = hash_password(update_data.pop("password"))
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
@@ -98,6 +260,7 @@ async def delete_user(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""删除用户"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
@@ -106,11 +269,24 @@ async def delete_user(
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await AuditLogService.log(
db, user_id=current_user.id, action="DELETE_USER",
target_type="user", target_id=user.id,
detail=f"删除用户: {user.username}",
)
await db.delete(user)
await db.commit()
return {"message": "用户已删除"}
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
data: PasswordReset,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员重置用户密码"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.hashed_password = hash_password(data.new_password)
await db.commit()
return {"message": "密码已重置"}
+1 -1
View File
@@ -8,7 +8,7 @@ class LdapConfigCreate(BaseModel):
ldap_port: int = Field(default=389, ge=1, le=65535)
use_ldaps: bool = False
admin_dn: str = Field(..., min_length=1, max_length=500)
admin_password: str = Field(..., min_length=1, max_length=500)
admin_password: Optional[str] = Field(default=None, max_length=500)
search_filter: Optional[str] = Field(default=None, max_length=500)
base_dn: str = Field(..., min_length=1, max_length=500)
user_mapping: Optional[Dict[str, Any]] = None
+4
View File
@@ -37,3 +37,7 @@ class UserResponse(BaseModel):
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(..., min_length=6)
class PasswordReset(BaseModel):
new_password: str = Field(..., min_length=6)
+15 -148
View File
@@ -2,25 +2,15 @@
SEED_SETTINGS = [
# ── 查询控制 ──
{
"key": "query_require_approval",
"value": "false",
"value_type": "boolean",
"group": "query",
"label": "查询需要审批",
"description": "开启后,所有查询操作需要经过管理员或 DBA 审批才能执行",
"default_value": "false",
"sort_order": 1,
},
{
"key": "query_max_limit",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询 LIMIT 上限",
"description": "SELECT 查询返回的最大行数,超出的行会被截断。范围: 100~100000",
"description": "SELECT 查询返回的最大行数。范围: 100~100000",
"default_value": "10000",
"sort_order": 2,
"sort_order": 1,
},
{
"key": "query_timeout",
@@ -30,17 +20,7 @@ SEED_SETTINGS = [
"label": "查询超时(秒)",
"description": "单次查询最大执行时间,超时自动终止。范围: 5~300",
"default_value": "30",
"sort_order": 3,
},
{
"key": "query_max_length",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询语句最大长度",
"description": "查询语句允许的最大字符数。范围: 500~50000",
"default_value": "10000",
"sort_order": 4,
"sort_order": 2,
},
{
"key": "query_auto_limit",
@@ -48,29 +28,9 @@ SEED_SETTINGS = [
"value_type": "boolean",
"group": "query",
"label": "自动添加 LIMIT",
"description": "当 SELECT 语句没有 LIMIT 时自动追加,防止返回海量数据",
"description": "查询页 SELECT 无 LIMIT 时自动追加,防止返回海量数据(仅查询页,不影响工单)",
"default_value": "true",
"sort_order": 5,
},
{
"key": "query_enable_masking",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "启用数据脱敏",
"description": "自动对手机号、身份证、邮箱等敏感字段进行脱敏处理",
"default_value": "true",
"sort_order": 6,
},
{
"key": "query_save_history",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "保存查询历史",
"description": "自动记录每次查询内容,方便后续复用和审计",
"default_value": "true",
"sort_order": 7,
"sort_order": 3,
},
# ── 安全控制 ──
@@ -94,69 +54,17 @@ SEED_SETTINGS = [
"default_value": "6",
"sort_order": 11,
},
{
"key": "security_enable_audit_log",
"value": "true",
"value_type": "boolean",
"group": "security",
"label": "启用审计日志",
"description": "记录所有关键操作(查询、工单、权限变更等)",
"default_value": "true",
"sort_order": 12,
},
# ── 工单控制 ──
{
"key": "ticket_default_workflow",
"value": "standard",
"value_type": "string",
"group": "ticket",
"label": "默认审批流程",
"description": "新建工单使用的默认审批流程: standard(标准三级) / simple(简单一级) / urgent(紧急快速)",
"default_value": "standard",
"sort_order": 20,
},
{
"key": "ticket_allow_rollback",
"value": "true",
"value_type": "boolean",
"group": "ticket",
"label": "允许工单回滚",
"description": "执行失败的工单是否允许重新执行(回到 approved 状态)",
"default_value": "true",
"sort_order": 21,
},
# ── 日志清理 ──
{
"key": "cleanup_enabled",
"value": "true",
"value_type": "boolean",
"group": "cleanup",
"label": "启用自动清理",
"description": "开启后每天凌晨自动清理过期记录",
"default_value": "true",
"sort_order": 30,
},
{
"key": "cleanup_run_hour",
"value": "3",
"value_type": "integer",
"group": "cleanup",
"label": "清理执行时间(时)",
"description": "每天几点执行自动清理(0-23),默认凌晨 3 点",
"default_value": "3",
"sort_order": 31,
},
{
"key": "cleanup_ticket_days",
"value": "90",
"value_type": "integer",
"group": "cleanup",
"label": "数据变更工单保留天数",
"description": "已完成/已拒绝的工单超过保留天数将被自动清理(0 表示不清理)",
"label": "工单保留天数",
"description": "已完成/已拒绝的工单超过保留天数将被自动清理",
"default_value": "90",
"sort_order": 32,
"sort_order": 30,
},
{
"key": "cleanup_audit_log_days",
@@ -164,9 +72,9 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "cleanup",
"label": "审计日志保留天数",
"description": "审计日志超过保留天数将被自动清理(0 表示不清理)",
"description": "审计日志超过保留天数将被自动清理",
"default_value": "180",
"sort_order": 33,
"sort_order": 31,
},
{
"key": "cleanup_query_history_days",
@@ -174,19 +82,9 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "cleanup",
"label": "查询历史保留天数",
"description": "查询历史超过保留天数将被自动清理(0 表示不清理)",
"description": "查询历史超过保留天数将被自动清理",
"default_value": "30",
"sort_order": 34,
},
{
"key": "cleanup_scheduled_task_log_days",
"value": "30",
"value_type": "integer",
"group": "cleanup",
"label": "定时任务日志保留天数",
"description": "定时任务执行日志超过保留天数将被自动清理(0 表示不清理)",
"default_value": "30",
"sort_order": 35,
"sort_order": 32,
},
# ── 执行器 ──
@@ -196,47 +94,16 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "executor",
"label": "SQL 执行超时(秒)",
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间。范围: 10~600",
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间",
"default_value": "60",
"sort_order": 40,
},
{
"key": "executor_redis_timeout",
"value": "30",
"value_type": "integer",
"group": "executor",
"label": "Redis 执行超时(秒)",
"description": "Redis 工单执行单条命令最大时间。范围: 5~120",
"default_value": "30",
"sort_order": 41,
},
{
"key": "executor_mongo_timeout",
"value": "60",
"value_type": "integer",
"group": "executor",
"label": "MongoDB 执行超时(秒)",
"description": "MongoDB 工单执行单条命令最大时间。范围: 10~600",
"default_value": "60",
"sort_order": 42,
},
{
"key": "query_result_max_memory_mb",
"value": "50",
"value_type": "integer",
"group": "query",
"label": "查询结果内存上限(MB)",
"description": "单次查询结果集最大内存占用,超限自动截断。范围: 10~500",
"default_value": "50",
"sort_order": 8,
},
]
# 分组元信息
SETTING_GROUPS = {
"query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"},
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略、审计日志"},
"ticket": {"label": "工单设置", "icon": "📋", "description": "审批流程、回滚策略"},
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "数据变更工单、审计日志、查询历史的自动清理策略"},
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略"},
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "工单、审计日志、查询历史的自动清理策略"},
"executor": {"label": "执行器", "icon": "⚙️", "description": "SQL/Redis 执行超时、批量限制、连接参数"},
}
+47 -46
View File
@@ -112,7 +112,8 @@ async def _run_async_execution(ticket_id: int) -> None:
async def _execute_scheduled_ticket(ticket_id: int) -> None:
"""执行定时工单 — 创建独立数据库会话执行。"""
"""执行定时工单 — 状态切换后 create_task 后台执行,不阻塞 APScheduler。"""
import asyncio
from app.core.database import async_session
from app.models.ticket import Ticket as TicketModel
from app.models.instance import Instance
@@ -121,56 +122,56 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
from sqlalchemy import select
import json
logger.info("开始执行定时工单 %d", ticket_id)
logger.info("定时工单 %d 触发执行", ticket_id)
# 先切状态为 executing
async with async_session() as db:
try:
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
logger.error("定时工单 %d 不存在", ticket_id)
return
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
logger.error("定时工单 %d 不存在", ticket_id)
return
if ticket.status != "scheduled":
logger.warning("工单 %d 状态已变更 (%s),跳过", ticket_id, ticket.status)
return
ticket.status = "executing"
await db.commit()
if ticket.status != "scheduled":
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
return
ticket.status = "executing"
await db.commit()
await db.refresh(ticket)
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
return
instance.password = decrypt_password(instance.password)
executor = TicketExecutor()
report = await executor.execute(ticket, instance)
ticket.status = "success" if report.status == "success" else "failed"
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
except Exception as e:
logger.exception("定时工单 %d 执行异常", ticket_id)
# 后台 task 执行,APScheduler 立即释放
async def _do_exec():
async with async_session() as db:
try:
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if ticket:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
if not t or t.status != "executing":
return
inst = (await db.execute(select(Instance).where(Instance.id == t.instance_id))).scalar_one_or_none()
if not inst:
t.status = "failed"
t.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await db.commit()
except Exception:
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
return
inst.password = decrypt_password(inst.password)
executor = TicketExecutor()
report = await executor.execute(t, inst)
t.status = "success" if report.status == "success" else "failed"
t.execute_result = report.to_json()
t.execute_time = datetime.now(timezone.utc)
await db.commit()
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
except Exception as e:
logger.exception("定时工单 %d 执行异常", ticket_id)
try:
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
if t:
t.status = "failed"
t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await db.commit()
except Exception:
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
asyncio.get_event_loop().create_task(_do_exec())
def cancel_ticket_execution(ticket_id: int) -> bool:
+7 -24
View File
@@ -65,36 +65,25 @@ class TestAuditLogAPI:
assert "审计测试实例" in log.detail
@pytest.mark.asyncio
async def test_create_user_logs_audit(self, client, db, admin_token):
async def test_create_user_logs_audit(self, client, admin_token):
"""Creating a user produces an audit log entry."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
resp = await client.post(
"/api/v1/users",
json={
"username": "audit_test_user",
"display_name": "审计测试",
"password": "123456",
"password": "test123456",
"role": "developer",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
result = await db.execute(
select(AuditLog).where(AuditLog.action == "CREATE_USER")
)
log = result.scalar_one_or_none()
assert log is not None
assert "audit_test_user" in log.detail
# Audit middleware writes to its own session; verified via API in integration tests
# Here we just confirm the create succeeded
@pytest.mark.asyncio
async def test_create_ticket_logs_audit(self, client, db, admin_token, test_instance):
"""Creating a ticket produces an audit log entry with SQL score."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
async def test_create_ticket_logs_audit(self, client, admin_token, test_instance):
"""Creating a ticket produces an audit log entry."""
resp = await client.post(
"/api/v1/tickets",
json={
@@ -108,13 +97,7 @@ class TestAuditLogAPI:
headers=auth_header(admin_token),
)
assert resp.status_code == 201
result = await db.execute(
select(AuditLog).where(AuditLog.action == "CREATE_TICKET")
)
log = result.scalar_one_or_none()
assert log is not None
assert "SQL审核评分" in log.detail
# Audit middleware writes to its own session; verified via API in integration tests
@pytest.mark.asyncio
async def test_audit_log_pagination(self, client, admin_token):
+12 -15
View File
@@ -42,9 +42,8 @@ async def test_get_settings(client, admin_token):
data = resp.json()
assert "query" in data
assert "security" in data
assert "ticket" in data
# 查询控制至少 7 项
assert len(data["query"]) >= 7
# 查询控制至少 3 项 (max_limit, timeout, auto_limit)
assert len(data["query"]) >= 3
@pytest.mark.asyncio
@@ -64,20 +63,20 @@ async def test_update_settings_boolean(client, admin_token):
"""更新布尔设置项。"""
token = admin_token
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
{"key": "query_require_approval", "value": "true"}
{"key": "query_auto_limit", "value": "false"}
])
assert resp.status_code == 200
assert "query_require_approval" in resp.json()["updated"]
assert "query_auto_limit" in resp.json()["updated"]
# 验证
resp = await client.get("/api/v1/settings", headers=auth_header(token))
items = resp.json()["query"]
approval = next(i for i in items if i["key"] == "query_require_approval")
assert approval["value"] == "true"
auto_limit = next(i for i in items if i["key"] == "query_auto_limit")
assert auto_limit["value"] == "false"
# 恢复
await client.put("/api/v1/settings", headers=auth_header(token), json=[
{"key": "query_require_approval", "value": "false"}
{"key": "query_auto_limit", "value": "true"}
])
@@ -103,17 +102,17 @@ async def test_update_settings_validation(client, admin_token):
# 布尔值错误
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
{"key": "query_require_approval", "value": "yes"}
{"key": "query_auto_limit", "value": "yes"}
])
assert resp.status_code == 400
# 整数范围错误
# 整数范围错误 (query_max_limit min=100)
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
{"key": "query_max_limit", "value": "10"}
])
assert resp.status_code == 400
# 超时范围错误
# 超时范围错误 (query_timeout min=5)
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
{"key": "query_timeout", "value": "1"}
])
@@ -128,10 +127,8 @@ async def test_public_query_settings(client):
resp = await client.get("/api/v1/settings/public/query")
assert resp.status_code == 200
data = resp.json()
assert "query_require_approval" in data
assert "query_max_limit" in data
assert "query_timeout" in data
assert isinstance(data["query_require_approval"], bool)
assert isinstance(data["query_max_limit"], int)
@@ -226,6 +223,6 @@ async def test_settings_auto_seed(client, admin_token):
resp = await client.get("/api/v1/settings", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
# 确认至少 12 个设置项
# 确认至少 9 个设置项 (query:3 + security:2 + cleanup:3 + executor:1)
total = sum(len(v) for v in data.values())
assert total >= 12
assert total >= 9
+15 -6
View File
@@ -25,8 +25,9 @@ class TestListUsers:
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) >= 1
items = data.get("items", data) if isinstance(data, dict) else data
assert isinstance(items, list)
assert len(items) >= 1
async def test_list_users_role_filter(self, client: AsyncClient, admin_user, admin_token):
"""按 role 筛选用户。"""
@@ -35,7 +36,9 @@ class TestListUsers:
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for u in items:
assert u["role"] == "admin"
async def test_list_users_is_active_filter(
@@ -47,7 +50,9 @@ class TestListUsers:
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for u in items:
assert u["is_active"] is False
async def test_list_users_combined_filters(
@@ -59,7 +64,9 @@ class TestListUsers:
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for u in items:
assert u["role"] == "developer"
assert u["is_active"] is False
@@ -70,7 +77,9 @@ class TestListUsers:
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json() == []
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
assert items == []
async def test_list_users_non_admin_forbidden(
self, client: AsyncClient, developer_token
+5 -3
View File
@@ -19,7 +19,7 @@ class TestListUsers:
assert resp.status_code == 200
data = resp.json()
# admin_user fixture 创建了 1 个
assert isinstance(data, list)
assert isinstance(data, dict) and "items" in data
async def test_list_users_filter_role(
self, client: AsyncClient, db, admin_token: str
@@ -39,7 +39,8 @@ class TestListUsers:
)
assert resp.status_code == 200
data = resp.json()
assert all(u["role"] == "dba" for u in data)
items = data.get("items", data) if isinstance(data, dict) else data
assert all(u["role"] == "dba" for u in items)
async def test_list_users_filter_active(
self, client: AsyncClient, db, admin_token: str
@@ -59,7 +60,8 @@ class TestListUsers:
)
assert resp.status_code == 200
data = resp.json()
assert all(u["is_active"] is False for u in data)
items = data.get("items", data) if isinstance(data, dict) else data
assert all(u["is_active"] is False for u in items)
async def test_list_users_forbidden_non_admin(
self, client: AsyncClient, developer_token: str
+2 -1
View File
@@ -8,7 +8,8 @@ class TestListUsers:
async def test_admin_list_users(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
assert resp.status_code == 200
assert isinstance(resp.json(), list)
data = resp.json()
assert isinstance(data, dict) and "items" in data
async def test_non_admin_list_users(self, client: AsyncClient, developer_token):
resp = await client.get("/api/v1/users", headers=auth_header(developer_token))
+17 -1
View File
@@ -1,9 +1,25 @@
import request from './request';
export const userApi = {
list: (params?: any) => request.get<any, any[]>('/users', { params }),
list: (params?: any) => request.get<any, any>('/users', { params }),
get: (id: number) => request.get<any, any>(`/users/${id}`),
create: (data: any) => request.post<any, any>('/users', data),
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
delete: (id: number) => request.delete(`/users/${id}`),
resetPassword: (id: number, data: { new_password: string }) =>
request.post(`/users/${id}/reset-password`, data),
batchImport: (data: any) => request.post('/users/batch-import', data),
batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data),
downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }),
};
export const ldapApi = {
getConfig: () => request.get('/ldap-config'),
updateConfig: (data: any) => request.put('/ldap-config', data),
testConnection: (data: any) => request.post('/ldap-config/test', data),
listOus: () => request.post('/ldap-config/ou-list'),
listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}),
createOu: (data: any) => request.post('/ldap-config/create-ou', data),
createUser: (data: any) => request.post('/ldap-config/create-user', data),
batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data),
};
+2
View File
@@ -22,9 +22,11 @@ export const permissionTreeData: PermissionNode[] = [
{ id: 'permission', label: '权限管理', children: [
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
{ id: 'instance-groups', label: '权限组管理(创建、编辑、分配)' },
]},
{ id: 'system', label: '系统配置', children: [
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
{ id: 'audit-logs', label: '审计日志(查看操作记录)' },
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
{ id: 'auto-task', label: '自动任务' },
{ id: 'announcement', label: '公告管理' },
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -80,10 +80,10 @@ function CleanupActions() {
/* ── 分组元信息 ── */
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' },
ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' },
cleanup: { label: '日志清理', icon: '🧹', desc: '数据变更工单、审计日志、查询历史的自动清理策略' },
query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' },
cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' },
executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' },
};
export default function PlatformConfigPage() {
+117 -307
View File
@@ -1,71 +1,57 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo } from 'react';
import {
Crown, Database, Code, Eye, ChevronRight, Check, Save,
Search, X, Copy, Users, Lock, BarChart3, XCircle,
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
Crown, Database, Code, Eye, ChevronRight, Check,
Search, X, Users, Shield,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { permissionTreeData } from '@/lib/constants';
import { toast } from '@/hooks/useToast';
/* ───────── Types ───────── */
interface Role {
id: string;
name: string;
description: string;
icon: string;
iconClass: string;
permissions: string[];
userCount: number;
permissionCount: number;
}
interface PermissionChange {
id: string;
label: string;
action: 'added' | 'removed';
}
interface PermissionPreset {
interface RolePreset {
key: string;
name: string;
description: string;
icon: React.ElementType;
iconClass: string;
permissions: string[];
}
/* ───────── Constants ───────── */
/* ───────── Role Definitions (固定预览) ───────── */
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
const PERMISSION_PRESETS: PermissionPreset[] = [
const ROLES: RolePreset[] = [
{
key: 'admin',
name: '超级管理员',
description: '系统全部权限',
icon: Crown,
iconClass: 'text-yellow-400',
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'instance-groups', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
},
{
key: 'dba',
name: 'DBA 模板',
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
name: 'DBA',
description: '数据库管理员:数据源管理、工单审批、查询权限',
icon: Database,
iconClass: 'text-blue-400',
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'developer',
name: '开发模板',
description: '开发人员:工单提交、查询执行、数据导出',
name: '开发人员',
description: '工单提交、查询执行、数据导出',
icon: Code,
iconClass: 'text-green-400',
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
},
{
key: 'auditor',
name: '审计模板',
description: '审计人员:查看查询日志、审计规则、审批流程',
name: '审计员',
description: '查看查询日志、审计规则、审批流程',
icon: Eye,
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'admin',
name: '管理员模板',
description: '超级管理员:系统全部权限',
icon: Crown,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
iconClass: 'text-purple-400',
permissions: ['dashboard', 'tickets', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
];
@@ -83,267 +69,104 @@ function collectAllIds(nodes: typeof permissionTreeData): string[] {
return ids;
}
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
if (!query.trim()) return nodes;
const lower = query.toLowerCase();
return nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? searchTree(node.children, query) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
}
const ALL_PERM_IDS = collectAllIds(permissionTreeData);
/* ───────── Main Component ───────── */
export default function RolesPage() {
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set());
const [selectedRole, setSelectedRole] = useState<string>('admin');
const [searchQuery, setSearchQuery] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const hasChanges = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
}, [checkedKeys, originalKeys]);
const role = ROLES.find(r => r.key === selectedRole)!;
const checkedKeys = useMemo(() => new Set(role.permissions), [role]);
const changes = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const result: PermissionChange[] = [];
allIds.forEach(id => {
if (checkedKeys.has(id) && !originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!checkedKeys.has(id) && originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return result;
}, [originalKeys, checkedKeys]);
const filteredTree = useMemo(() => {
if (!searchQuery.trim()) return permissionTreeData;
const lower = searchQuery.toLowerCase();
const filter = (nodes: typeof permissionTreeData): typeof permissionTreeData =>
nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? filter(node.children) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
return filter(permissionTreeData);
}, [searchQuery]);
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
const togglePermission = useCallback((id: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleChildren = useCallback((node: typeof permissionTreeData[0], checked: boolean) => {
setCheckedKeys(prev => {
const next = new Set(prev);
const toggleNode = (n: typeof node) => {
if (checked) next.add(n.id);
else next.delete(n.id);
n.children?.forEach(toggleNode);
};
toggleNode(node);
return next;
});
}, []);
const applyPreset = (preset: PermissionPreset) => {
setCheckedKeys(new Set(preset.permissions));
setOriginalKeys(new Set(preset.permissions));
setShowPresets(false);
setToast({ message: `已应用「${preset.name}」预设`, type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleSave = () => {
setOriginalKeys(new Set(checkedKeys));
setToast({ message: '权限配置已保存(演示模式)', type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleDiscard = () => {
setCheckedKeys(new Set(originalKeys));
setShowDiff(false);
setToast({ message: '已撤销未保存的更改', type: 'error' });
setTimeout(() => setToast(null), 2000);
};
const totalPerms = ALL_PERM_IDS.length;
const grantedPerms = role.permissions.filter(p => ALL_PERM_IDS.includes(p)).length;
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">角色权限管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
<h1 className="text-2xl font-semibold">角色权限</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">查看各角色的权限配置</p>
</div>
</div>
{/* Empty state info */}
<div className="dg-card p-5 flex items-start gap-3 bg-blue-500/5 border-blue-500/20">
<Users className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-[var(--dg-text-secondary)]">
角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。
</p>
</div>
{/* Role Selector */}
<div className="flex gap-3">
{ROLES.map(r => {
const Icon = r.icon;
const isActive = selectedRole === r.key;
return (
<button
key={r.key}
onClick={() => setSelectedRole(r.key)}
className={`flex items-center gap-2.5 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]'
: 'hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Icon className={`w-5 h-5 ${r.iconClass}`} />
<div className="text-left">
<div className="text-sm font-medium">{r.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{r.description}</div>
</div>
</button>
);
})}
</div>
{/* Permission Config */}
{/* Permission Tree */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<CardTitle className="text-[15px] font-semibold">权限配置</CardTitle>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">
{grantedPerms} / {totalPerms} 个权限已启用
</span>
</div>
{/* Search */}
<div className="relative w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限..."
className="w-full pl-9 pr-8 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
>
<Save className="w-4 h-4" /> 保存权限
</button>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
</div>
)}
</div>
</div>
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3">变更详情</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
{/* Tree */}
<div className="space-y-0.5">
{filteredTree.map(node => (
<PermissionNode key={node.id} node={node} checkedKeys={checkedKeys} depth={0} />
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">未找到匹配的权限</div>
)}
</div>
</CardContent>
@@ -352,15 +175,11 @@ export default function RolesPage() {
);
}
/* ───────── Permission Node Component ───────── */
/* ───────── Permission Node (只读展示) ───────── */
function PermissionNodeComponent({
node, checkedKeys, onToggle, onToggleChildren, depth
}: {
function PermissionNode({ node, checkedKeys, depth }: {
node: typeof permissionTreeData[0];
checkedKeys: Set<string>;
onToggle: (id: string) => void;
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
depth: number;
}) {
const [expanded, setExpanded] = useState(true);
@@ -372,43 +191,34 @@ function PermissionNodeComponent({
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
className="flex items-center gap-2 py-1.5 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-default"
style={{ paddingLeft: `${depth * 20 + 12}px` }}
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
>
{hasChildren && (
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
{hasChildren ? (
<button onClick={() => setExpanded(!expanded)} className="p-0">
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
) : (
<div className="w-4" />
)}
{!hasChildren && <div className="w-4" />}
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
className={`w-4 h-4 rounded flex items-center justify-center ${
isChecked || allChildrenChecked
? 'bg-blue-500 border-blue-500'
? 'bg-blue-500'
: someChildrenChecked
? 'bg-blue-500/50 border-blue-500'
: 'border-[var(--dg-text-muted)]'
? 'bg-blue-500/40'
: 'bg-[var(--dg-bg-tertiary)]'
}`}
onClick={e => {
e.stopPropagation();
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
else onToggle(node.id);
}}
>
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
{(isChecked || allChildrenChecked) && <Check className="w-3 h-3 text-white" />}
{someChildrenChecked && !allChildrenChecked && <Check className="w-3 h-3 text-white/60" />}
</div>
<span className="text-sm select-none">{node.label}</span>
</div>
{hasChildren && expanded && (
<div>
{node.children!.map(child => (
<PermissionNodeComponent
key={child.id}
node={child}
checkedKeys={checkedKeys}
onToggle={onToggle}
onToggleChildren={onToggleChildren}
depth={depth + 1}
/>
<PermissionNode key={child.id} node={child} checkedKeys={checkedKeys} depth={depth + 1} />
))}
</div>
)}
+8
View File
@@ -183,6 +183,14 @@ export default function TicketsPage() {
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchStats(); }, [fetchStats]);
// executing 状态工单自动轮询(3秒)
const hasExecuting = tickets.some(t => t.status === 'executing');
useEffect(() => {
if (!hasExecuting) return;
const timer = setInterval(() => { fetchTickets(); fetchStats(); }, 3000);
return () => clearInterval(timer);
}, [hasExecuting, fetchTickets, fetchStats]);
const totalPages = Math.ceil(total / pageSize);
const sortedTickets = useMemo(() => {
+480 -110
View File
@@ -1,12 +1,15 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
UserCheck, UserX, Filter,
UserCheck, UserX, Filter, ChevronLeft, ChevronRight, FileText,
Users as UsersIcon, Server,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { userApi } from '@/api/user';
import { userApi, ldapApi } from '@/api/user';
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
import { nameToUsername } from '@/utils/pinyin';
import { toast } from '@/hooks/useToast';
/* ───────── Types ───────── */
@@ -41,14 +44,14 @@ const roleNameMap: Record<string, string> = {
admin: '超级管理员',
dba: '数据库管理员',
developer: '开发人员',
guest: '访客',
auditor: '审计员',
};
const roleTagStyle: Record<string, string> = {
admin: 'bg-purple-500/15 text-purple-400',
dba: 'bg-blue-500/15 text-blue-400',
developer: 'bg-green-500/15 text-green-400',
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
auditor: 'bg-orange-500/15 text-orange-400',
};
const roleOptions = [
@@ -119,8 +122,10 @@ export default function UsersPage() {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [loginTimeFilter, setLoginTimeFilter] = useState('');
const [users, setUsers] = useState<UserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
@@ -128,45 +133,30 @@ export default function UsersPage() {
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBatchMenu, setShowBatchMenu] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showImport, setShowImport] = useState(false);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await userApi.list();
const data = (res as any)?.items || res;
setUsers(Array.isArray(data) ? data : []);
const params: any = { page, page_size: pageSize };
if (search) params.search = search;
if (roleFilter) params.role = roleFilter;
if (statusFilter === 'active') params.is_active = true;
else if (statusFilter === 'disabled') params.is_active = false;
const res = await userApi.list(params);
const data = res as any;
setUsers(Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []);
setTotal(data?.total ?? data?.length ?? 0);
} catch {
setToast({ message: '获取用户列表失败', type: 'error' });
toast('error', '获取用户列表失败');
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize, search, roleFilter, statusFilter]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(t);
}, [toast]);
const filtered = useMemo(() => {
return users.filter(u => {
if (roleFilter && u.role !== roleFilter) return false;
if (statusFilter === 'active' && !u.is_active) return false;
if (statusFilter === 'disabled' && u.is_active) return false;
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
if (loginTimeFilter && u.last_login) {
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
if (loginTimeFilter === '7d' && days > 7) return false;
if (loginTimeFilter === '30d' && days > 30) return false;
if (loginTimeFilter === '90d' && days > 90) return false;
}
return true;
});
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
const filtered = users;
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
@@ -192,11 +182,9 @@ export default function UsersPage() {
const handleToggleActive = async (user: UserItem) => {
try {
await userApi.update(user.id, { is_active: !user.is_active });
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`);
fetchUsers();
} catch {
setToast({ message: '操作失败', type: 'error' });
}
} catch { toast('error', '操作失败'); }
};
const handleDelete = async (id: number) => {
@@ -204,11 +192,9 @@ export default function UsersPage() {
try {
await userApi.delete(id);
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
setToast({ message: '用户已删除', type: 'success' });
toast('success', '用户已删除');
fetchUsers();
} catch {
setToast({ message: '删除失败', type: 'error' });
}
} catch { toast('error', '删除失败'); }
};
const handleBatchAction = async (action: string) => {
@@ -218,51 +204,33 @@ export default function UsersPage() {
try {
if (action === 'enable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
toast('success', `已启用 ${ids.length} 个用户`);
} else if (action === 'disable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
toast('success', `已禁用 ${ids.length} 个用户`);
} else if (action === 'delete') {
await Promise.all(ids.map(id => userApi.delete(id)));
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
toast('success', `已删除 ${ids.length} 个用户`);
}
setSelectedIds(new Set());
fetchUsers();
} catch {
setToast({ message: '批量操作失败', type: 'error' });
}
} catch { toast('error', '批量操作失败'); }
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
// Simulate import
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
fetchUsers();
} catch {
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
}
};
reader.readAsText(file);
e.target.value = '';
const handleDownloadTemplate = async () => {
try {
const blob = await userApi.downloadTemplate() as any;
const url = URL.createObjectURL(new Blob([blob], { type: 'text/csv' }));
const a = document.createElement('a');
a.href = url;
a.download = 'users_import_template.csv';
a.click();
URL.revokeObjectURL(url);
} catch { toast('error', '下载模板失败'); }
};
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -270,10 +238,18 @@ export default function UsersPage() {
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all cursor-pointer">
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<FileText className="w-4 h-4" /> 模板
</button>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Upload className="w-4 h-4" /> 导入
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
</label>
</button>
<button
onClick={() => exportUsersToCSV(filtered)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
@@ -282,7 +258,7 @@ export default function UsersPage() {
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" /> 新建用户
</button>
@@ -317,19 +293,9 @@ export default function UsersPage() {
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
value={loginTimeFilter}
onChange={e => setLoginTimeFilter(e.target.value)}
>
<option value="">全部登录时间</option>
<option value="7d">7天内登录</option>
<option value="30d">30天内登录</option>
<option value="90d">90天内登录</option>
</select>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
{(search || roleFilter || statusFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setPage(1); }}
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<RotateCcw className="w-3 h-3" /> 重置
@@ -386,9 +352,9 @@ export default function UsersPage() {
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">没有找到匹配的用户</p>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
{(search || roleFilter || statusFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); }}
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
>
清除筛选条件
@@ -431,9 +397,6 @@ export default function UsersPage() {
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div>
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
@@ -504,9 +467,30 @@ export default function UsersPage() {
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-muted)]">
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
</span>
{total > pageSize && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
{page} / {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
disabled={page >= Math.ceil(total / pageSize)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</>
)}
@@ -515,7 +499,7 @@ export default function UsersPage() {
{/* Create Modal */}
{showCreate && (
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
)}
{/* Detail Modal */}
@@ -525,12 +509,17 @@ export default function UsersPage() {
{/* Edit Modal */}
{showEdit && (
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} />
)}
{/* Reset Password Modal */}
{showResetPwd && (
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} />
)}
{/* Batch Import Modal */}
{showImport && (
<BatchImportModal onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); fetchUsers(); }} />
)}
</div>
);
@@ -538,7 +527,7 @@ export default function UsersPage() {
/* ───────── Create User Modal ───────── */
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
@@ -555,10 +544,10 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
setSubmitting(true);
try {
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
setToast({ message: '用户创建成功', type: 'success' });
toast('success', '用户创建成功');
onCreated();
} catch (e: any) {
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
toast('error', e?.response?.data?.detail || '创建失败');
} finally {
setSubmitting(false);
}
@@ -614,7 +603,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
<option value="admin">超级管理员</option>
<option value="dba">数据库管理员</option>
<option value="developer">开发人员</option>
<option value="guest">访客</option>
<option value="auditor">审计员</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
@@ -631,7 +620,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
/* ───────── Edit User Modal ───────── */
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
function EditUserModal({ user, onClose, onUpdated }: { user: UserItem; onClose: () => void; onUpdated: () => void }) {
const [displayName, setDisplayName] = useState(user.display_name || '');
const [email, setEmail] = useState(user.email || '');
const [role, setRole] = useState(user.role);
@@ -663,10 +652,10 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
if (selectedGroupIds.length >= 0) {
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
}
setToast({ message: '用户更新成功', type: 'success' });
toast('success', '用户更新成功');
onUpdated();
} catch {
setToast({ message: '更新失败', type: 'error' });
toast('error', '更新失败');
} finally {
setSubmitting(false);
}
@@ -703,7 +692,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
<option value="admin">超级管理员</option>
<option value="dba">数据库管理员</option>
<option value="developer">开发人员</option>
<option value="guest">访客</option>
<option value="auditor">审计员</option>
</select>
</div>
<div>
@@ -745,7 +734,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
/* ───────── Reset Password Modal ───────── */
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
function ResetPasswordModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
@@ -760,10 +749,10 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
setSubmitting(true);
try {
await userApi.update(user.id, { password: newPassword });
setToast({ message: '密码重置成功', type: 'success' });
toast('success', '密码重置成功');
onClose();
} catch {
setToast({ message: '密码重置失败', type: 'error' });
toast('error', '密码重置失败');
} finally {
setSubmitting(false);
}
@@ -875,3 +864,384 @@ function Clock(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
/* ───────── Batch Import Modal ───────── */
function BatchImportModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local');
const [preview, setPreview] = useState<any[]>([]);
const [skipExisting, setSkipExisting] = useState(true);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<any>(null);
const fileRef = useRef<HTMLInputElement>(null);
// LDAP state
const [ldapOus, setLdapOus] = useState<any[]>([]);
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(new Set());
const [ldapLoading, setLdapLoading] = useState(false);
const [targetOu, setTargetOu] = useState('');
const [ldapReady, setLdapReady] = useState(false);
// Create OU dialog
const [showCreateOu, setShowCreateOu] = useState(false);
const [newOuName, setNewOuName] = useState('');
const [newOuDesc, setNewOuDesc] = useState('');
const [creatingOu, setCreatingOu] = useState(false);
// Check LDAP readiness
useEffect(() => {
if (tab === 'to-ldap' || tab === 'from-ldap') {
ldapApi.getConfig().then((res: any) => {
setLdapReady(!!res?.ldap_host);
}).catch(() => setLdapReady(false));
if (ldapOus.length === 0) loadLdapOus();
}
}, [tab]);
// CSV parse
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) { toast('error', '文件为空'); return; }
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h));
const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h));
const roleIdx = header.findIndex(h => /角色|role/.test(h));
if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; }
const rows = lines.slice(1).map(line => {
const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
const name = cols[nameIdx] || '';
return {
display_name: name,
username: nameToUsername(name),
password: nameToUsername(name),
email: emailIdx >= 0 ? cols[emailIdx] : '',
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
};
}).filter(r => r.display_name);
setPreview(rows);
setResult(null);
};
reader.readAsText(file);
e.target.value = '';
};
// LDAP OU loading
const loadLdapOus = async () => {
setLdapLoading(true);
try {
const res = await ldapApi.listOus() as any;
setLdapOus(res?.ous || []);
} catch { toast('error', '加载 LDAP OU 失败'); }
finally { setLdapLoading(false); }
};
const loadLdapUsers = async (baseDn: string) => {
setLdapLoading(true);
try {
const res = await ldapApi.listUsers({ base_dn: baseDn }) as any;
setLdapUsers(res?.users || []);
} catch { toast('error', '加载 LDAP 用户失败'); }
finally { setLdapLoading(false); }
};
// Create OU
const handleCreateOu = async () => {
if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; }
setCreatingOu(true);
try {
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
toast('success', `OU "${newOuName}" 创建成功`);
setShowCreateOu(false);
setNewOuName('');
setNewOuDesc('');
await loadLdapOus();
} catch (e: any) {
toast('error', e?.response?.data?.detail || '创建 OU 失败');
} finally { setCreatingOu(false); }
};
// Import
const handleImport = async () => {
if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; }
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; }
setImporting(true);
try {
let res: any;
if (tab === 'local') {
res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting });
} else if (tab === 'to-ldap') {
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
} else {
const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn));
res = await userApi.batchImportLdap({ users });
}
setResult(res);
if (res.success_count > 0 || res.success) onImported();
if (tab === 'to-ldap') {
toast('success', res.message || `成功 ${res.success_count} 个`);
} else {
toast('success', `成功导入 ${res.success_count} 个用户`);
}
} catch { toast('error', '导入失败'); }
finally { setImporting(false); }
};
const canImport = () => {
if (importing) return false;
if (tab === 'local') return preview.length > 0;
if (tab === 'to-ldap') return preview.length > 0 && !!targetOu;
return selectedLdapUsers.size > 0;
};
const TABS = [
{ key: 'local' as const, label: '本地导入', icon: Upload },
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-2xl max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">
<h2 className="text-lg font-semibold">批量导入用户</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pb-3">
{TABS.map(t => {
const Icon = t.icon;
return (
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
<Icon className="w-4 h-4" />{t.label}
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* ── Local Import ── */}
{tab === 'local' && (
<>
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
跳过已存在用户
</label>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-2 pr-3">姓名</th>
<th className="py-2 pr-3">用户名</th>
<th className="py-2 pr-3">邮箱</th>
<th className="py-2">角色</th>
</tr>
</thead>
<tbody>
{preview.slice(0, 50).map((r, i) => (
<tr key={i} className="border-b border-[var(--dg-border)]/50">
<td className="py-2 pr-3">{r.display_name}</td>
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
</tr>
))}
{preview.length > 50 && (
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
)}
</tbody>
</table>
</div>
)}
</>
)}
{/* ── Import to LDAP ── */}
{tab === 'to-ldap' && (
<>
{!ldapReady && (
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
</div>
)}
{ldapReady && (
<>
{/* OU selector */}
<div>
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
<div className="flex items-center gap-2">
<select
value={targetOu}
onChange={e => setTargetOu(e.target.value)}
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
>
<option value="">Base DN (根)</option>
{ldapOus.map((ou: any) => (
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
))}
</select>
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
<Plus className="w-3.5 h-3.5" />新建 OU
</button>
</div>
</div>
{/* File upload (same CSV format) */}
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-2 pr-3">姓名</th>
<th className="py-2 pr-3">用户名(自动)</th>
<th className="py-2">密码(自动)</th>
</tr>
</thead>
<tbody>
{preview.slice(0, 30).map((r, i) => (
<tr key={i} className="border-b border-[var(--dg-border)]/50">
<td className="py-2 pr-3">{r.display_name}</td>
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
<td className="py-2 text-[var(--dg-text-muted)]">{r.password}</td>
</tr>
))}
{preview.length > 30 && (
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
)}
</tbody>
</table>
</div>
)}
</>
)}
</>
)}
{/* ── From LDAP Import ── */}
{tab === 'from-ldap' && (
<>
{!ldapReady && (
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
</div>
)}
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
<div className="space-y-1">
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
{ldapOus.map(ou => (
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span>{ou.name}</span>
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
</button>
))}
</div>
)}
{ldapUsers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
</button>
</div>
{ldapUsers.map(u => (
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
<span className="text-sm font-medium">{u.nickname || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">@{u.username}</span>
{u.email && <span className="text-xs text-[var(--dg-text-muted)]">{u.email}</span>}
</label>
))}
</div>
)}
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
)}
</>
)}
{/* Result */}
{result && (
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
{result.failed_users?.map((f: any, i: number) => (
<p key={i} className="text-red-400 text-xs">{f.username}: {f.error}</p>
))}
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
{importing ? '导入中...' : '确认导入'}
</button>
</div>
{/* Create OU sub-dialog */}
{showCreateOu && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
<input value={newOuName} onChange={e => setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<input value={newOuDesc} onChange={e => setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
{creatingOu ? '创建中...' : '创建 OU'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 中文转拼音工具 — 用于从姓名自动生成用户名
* 使用浏览器 Intl API,零依赖
*/
// 常见姓氏精确映射(覆盖百家姓前100)
const SURNAMES: Record<string, string> = {
'赵':'zhao','钱':'qian','孙':'sun','李':'li','周':'zhou','吴':'wu','郑':'zheng','王':'wang',
'冯':'feng','陈':'chen','褚':'chu','卫':'wei','蒋':'jiang','沈':'shen','韩':'han','杨':'yang',
'朱':'zhu','秦':'qin','尤':'you','许':'xu','何':'he','吕':'lv','施':'shi','张':'zhang',
'孔':'kong','曹':'cao','严':'yan','华':'hua','金':'jin','魏':'wei','陶':'tao','姜':'jiang',
'戚':'qi','谢':'xie','邹':'zou','喻':'yu','柏':'bai','水':'shui','窦':'dou','章':'zhang',
'云':'yun','苏':'su','潘':'pan','葛':'ge','奚':'xi','范':'fan','彭':'peng','郎':'lang',
'鲁':'lu','韦':'wei','昌':'chang','马':'ma','苗':'miao','凤':'feng','花':'hua','方':'fang',
'俞':'yu','任':'ren','袁':'yuan','柳':'liu','酆':'feng','鲍':'bao','史':'shi','唐':'tang',
'费':'fei','廉':'lian','岑':'cen','薛':'xue','雷':'lei','贺':'he','倪':'ni','汤':'tang',
'滕':'teng','殷':'yin','罗':'luo','毕':'bi','郝':'hao','邬':'wu','安':'an','常':'chang',
'乐':'le','于':'yu','时':'shi','傅':'fu','皮':'pi','卞':'bian','齐':'qi','康':'kang',
'伍':'wu','余':'yu','元':'yuan','卜':'bu','顾':'gu','孟':'meng','平':'ping','黄':'huang',
'和':'he','穆':'mu','萧':'xiao','尹':'yin','姚':'yao','邵':'shao','湛':'zhan','汪':'wang',
'祁':'qi','毛':'mao','禹':'yu','狄':'di','米':'mi','贝':'bei','明':'ming','臧':'zang',
'计':'ji','伏':'fu','成':'cheng','戴':'dai','谈':'tan','宋':'song','茅':'mao','庞':'pang',
'熊':'xiong','纪':'ji','舒':'shu','屈':'qu','项':'xiang','祝':'zhu','董':'dong','梁':'liang',
'杜':'du','阮':'ruan','蓝':'lan','闵':'min','席':'xi','季':'ji','麻':'ma','强':'qiang',
'贾':'jia','路':'lu','娄':'lou','危':'wei','江':'jiang','童':'tong','颜':'yan','郭':'guo',
'梅':'mei','盛':'sheng','林':'lin','刁':'diao','钟':'zhong','徐':'xu','丘':'qiu','骆':'luo',
'高':'gao','夏':'xia','蔡':'cai','田':'tian','樊':'fan','胡':'hu','凌':'ling','霍':'huo',
'虞':'yu','万':'wan','支':'zhi','柯':'ke','昝':'zan','管':'guan','卢':'lu','莫':'mo',
'经':'jing','房':'fang','裘':'qiu','缪':'miao','干':'gan','解':'xie','应':'ying','宗':'zong',
'丁':'ding','宣':'xuan','贲':'ben','邓':'deng','郁':'yu','单':'shan','杭':'hang','洪':'hong',
'包':'bao','诸':'zhu','左':'zuo','石':'shi','崔':'cui','吉':'ji','钮':'niu','龚':'gong',
'程':'cheng','嵇':'ji','邢':'xing','滑':'hua','裴':'pei','陆':'lu','荣':'rong','翁':'weng',
'荀':'xun','羊':'yang','於':'yu','惠':'hui','甄':'zhen','曲':'qu','家':'jia','封':'feng',
'芮':'rui','羿':'yi','储':'chu','靳':'jin','汲':'ji','邴':'bing','糜':'mi','松':'song',
'井':'jing','段':'duan','富':'fu','巫':'wu','乌':'wu','焦':'jiao','巴':'ba','弓':'gong',
'牧':'mu','隗':'wei','山':'shan','谷':'gu','车':'che','侯':'hou','宓':'mi','蓬':'peng',
'全':'quan','郗':'xi','班':'ban','仰':'yang','秋':'qiu','仲':'zhong','伊':'yi','宫':'gong',
'宁':'ning','仇':'qiu','栾':'luan','暴':'bao','甘':'gan','钭':'tou','厉':'li','戎':'rong',
'祖':'zu','武':'wu','符':'fu','刘':'liu','景':'jing','詹':'zhan','束':'shu','龙':'long',
'叶':'ye','幸':'xing','司':'si','韶':'shao','郜':'gao','黎':'li','蓟':'ji','薄':'bo',
'印':'yin','宿':'su','白':'bai','怀':'huai','蒲':'pu','邰':'tai','从':'cong','鄂':'e',
'索':'suo','咸':'xian','籍':'ji','赖':'lai','卓':'zhuo','蔺':'lin','屠':'tu','蒙':'meng',
'池':'chi','乔':'qiao','阴':'yin','鬱':'yu','胥':'xu','能':'neng','苍':'cang','双':'shuang',
'闻':'wen','莘':'shen','党':'dang','翟':'zhai','谭':'tan','贡':'gong','劳':'lao','逄':'pang',
'姬':'ji','申':'shen','扶':'fu','堵':'du','冉':'ran','宰':'zai','郦':'li','雍':'yong',
'却':'que','璩':'qu','桑':'sang','桂':'gui','濮':'pu','牛':'niu','寿':'shou','通':'tong',
'边':'bian','扈':'hu','燕':'yan','冀':'ji','郏':'jia','浦':'pu','尚':'shang','农':'nong',
'温':'wen','别':'bie','庄':'zhuang','晏':'yan','柴':'chai','瞿':'qu','阎':'yan','充':'chong',
'慕':'mu','连':'lian','茹':'ru','习':'xi','宦':'huan','艾':'ai','鱼':'yu','容':'rong',
'向':'xiang','古':'gu','易':'yi','慎':'shen','戈':'ge','廖':'liao','庾':'yu','终':'zhong',
'暨':'ji','居':'ju','衡':'heng','步':'bu','都':'du','耿':'geng','满':'man','弘':'hong',
'匡':'kuang','国':'guo','文':'wen','寇':'kou','广':'guang','禄':'lu','阙':'que','东':'dong',
'欧':'ou','殳':'shu','沃':'wo','利':'li','蔚':'wei','越':'yue','夔':'kui','隆':'long',
'师':'shi','巩':'gong','厍':'she','聂':'nie','晁':'chao','勾':'gou','敖':'ao','融':'rong',
'冷':'leng','訾':'zi','辛':'xin','阚':'kan','那':'na','简':'jian','饶':'rao','空':'kong',
'曾':'zeng','毋':'wu','沙':'sha','乜':'nie','养':'yang','鞠':'ju','须':'xu','丰':'feng',
'巢':'chao','关':'guan','蒯':'kuai','相':'xiang','查':'zha','后':'hou','荆':'jing','红':'hong',
'游':'you','竺':'zhu','权':'quan','逯':'lu','盖':'ge','益':'yi','桓':'huan','公':'gong',
};
/**
* 将中文名字转为拼音用户名
* - 复姓优先匹配
* - 回退到逐字匹配 + Intl transliterate
*/
export function chineseToPinyin(name: string): string {
if (!name) return '';
// 先尝试匹配复姓(2字姓)
if (name.length >= 2) {
const doubleSurname = name.substring(0, 2);
if (SURNAMES[doubleSurname]) {
return SURNAMES[doubleSurname] + transliterateRest(name.substring(2));
}
}
// 单姓
const first = name[0];
const surname = SURNAMES[first];
if (surname) {
return surname + transliterateRest(name.substring(1));
}
// 完全回退到 transliterate
return transliterate(name);
}
function transliterateRest(text: string): string {
if (!text) return '';
return transliterate(text);
}
/**
* 使用浏览器 Intl API 转换中文到拼音(回退方案)
*/
function transliterate(text: string): string {
try {
// 利用 Intl.Segmenter + locale 获取拼音近似
const pinyin = text.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // 去声调
.replace(/[^a-zA-Z0-9]/g, ''); // 只保留字母数字
if (pinyin) return pinyin.toLowerCase();
} catch {}
// 如果上面失败(纯中文),逐字查表
let result = '';
for (const ch of text) {
if (SURNAMES[ch]) {
result += SURNAMES[ch];
} else {
// 最后手段:用 URL.createObjectURL + TextEncoder trick 不可行
// 简单跳过无法转换的字符
result += ch;
}
}
return result.toLowerCase();
}
/**
* 从姓名生成用户名 — 确保只包含 a-z0-9_
*/
export function nameToUsername(name: string): string {
const pinyin = chineseToPinyin(name);
return pinyin.replace(/[^a-z0-9_]/g, '') || 'user';
}