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:
1 parent
344c519209
commit
be2d6f3894
20 files changed
+2229
-1019
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -71,12 +72,15 @@ async def upsert_ldap_config(
|
|||||||
ldap.ldap_port = data.ldap_port
|
ldap.ldap_port = data.ldap_port
|
||||||
ldap.use_ldaps = data.use_ldaps
|
ldap.use_ldaps = data.use_ldaps
|
||||||
ldap.admin_dn = data.admin_dn
|
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.search_filter = data.search_filter
|
||||||
ldap.base_dn = data.base_dn
|
ldap.base_dn = data.base_dn
|
||||||
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
|
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
|
||||||
else:
|
else:
|
||||||
# Create new
|
# Create new — 首次必须提供密码
|
||||||
|
if not data.admin_password:
|
||||||
|
raise HTTPException(status_code=400, detail="首次配置 LDAP 必须提供管理员密码")
|
||||||
ldap = LdapConfig(
|
ldap = LdapConfig(
|
||||||
ldap_host=data.ldap_host,
|
ldap_host=data.ldap_host,
|
||||||
ldap_port=data.ldap_port,
|
ldap_port=data.ldap_port,
|
||||||
@@ -100,34 +104,81 @@ async def test_ldap_connection(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
"""测试 LDAP 连接"""
|
"""测试 LDAP 连接 + 用户 Lookup 详细信息"""
|
||||||
ldap = await _get_existing(db)
|
ldap = await _get_existing(db)
|
||||||
if not ldap:
|
if not ldap:
|
||||||
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import ldap as _ldap # noqa
|
import ldap as _ldap # noqa
|
||||||
# Real LDAP test path
|
|
||||||
protocol = "ldaps" if ldap.use_ldaps else "ldap"
|
protocol = "ldaps" if ldap.use_ldaps else "ldap"
|
||||||
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
|
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
|
||||||
conn = _ldap.initialize(uri)
|
conn = _ldap.initialize(uri)
|
||||||
conn.protocol_version = 3
|
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.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)"
|
search_filter = ldap.search_filter or "(uid=%s)"
|
||||||
|
user_filter = search_filter.replace("%s", data.test_user)
|
||||||
results = conn.search_s(
|
results = conn.search_s(
|
||||||
ldap.base_dn,
|
ldap.base_dn,
|
||||||
_ldap.SCOPE_SUBTREE,
|
_ldap.SCOPE_SUBTREE,
|
||||||
search_filter.replace("%s", data.test_user),
|
user_filter,
|
||||||
|
["uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"],
|
||||||
)
|
)
|
||||||
if not results:
|
if not results:
|
||||||
return {"success": False, "message": f"未找到用户: {data.test_user}"}
|
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)
|
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()
|
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:
|
except ImportError:
|
||||||
return {
|
return {
|
||||||
@@ -136,3 +187,355 @@ async def test_ldap_connection(
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "message": f"LDAP 连接失败: {str(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)}")
|
||||||
@@ -1237,108 +1237,69 @@ async def execute_ticket(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
# 8. 使用多数据库执行引擎
|
# 8. 后台异步执行(避免大表操作阻塞 HTTP 连接)
|
||||||
|
import asyncio
|
||||||
from app.services.ticket_executor import TicketExecutor
|
from app.services.ticket_executor import TicketExecutor
|
||||||
executor = TicketExecutor()
|
|
||||||
|
|
||||||
try:
|
async def _do_execute():
|
||||||
# 解密密码
|
"""后台执行工单,独立 session 更新结果。"""
|
||||||
instance.password = decrypt_password(instance.password)
|
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. 更新工单状态和结果
|
await _update_sql_items_from_report(bg_db, t.id, report)
|
||||||
if report.status == "success":
|
|
||||||
new_status = TicketStatus.SUCCESS.value
|
|
||||||
else:
|
|
||||||
new_status = TicketStatus.FAILED.value
|
|
||||||
|
|
||||||
# 验证状态转换
|
# 审计日志
|
||||||
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
|
log_detail = (
|
||||||
ticket.status = new_status
|
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
||||||
else:
|
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
|
||||||
ticket.status = TicketStatus.FAILED.value
|
)
|
||||||
|
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()
|
# 启动后台 task,立即返回 executing 状态
|
||||||
ticket.execute_time = datetime.now(timezone.utc)
|
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)
|
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
|
return ticket_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,70 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select, func
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user, hash_password, require_role
|
from app.core.security import get_current_user, hash_password, require_role
|
||||||
from app.models.user import User
|
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
|
from app.services.audit_log_service import AuditLogService
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[UserResponse])
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
role: Optional[str] = None,
|
page: int = Query(1, ge=1),
|
||||||
is_active: Optional[bool] = None,
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
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:
|
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:
|
if is_active is not None:
|
||||||
stmt = stmt.where(User.is_active == is_active)
|
query = query.where(User.is_active == is_active)
|
||||||
result = await db.execute(stmt)
|
count_q = count_q.where(User.is_active == is_active)
|
||||||
return result.scalars().all()
|
|
||||||
|
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)
|
@router.post("", response_model=UserResponse, status_code=201)
|
||||||
@@ -35,6 +73,7 @@ async def create_user(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
|
"""创建用户"""
|
||||||
exists = await db.execute(select(User).where(User.username == data.username))
|
exists = await db.execute(select(User).where(User.username == data.username))
|
||||||
if exists.scalar_one_or_none():
|
if exists.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
@@ -42,29 +81,149 @@ async def create_user(
|
|||||||
user = User(
|
user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
display_name=data.display_name,
|
display_name=data.display_name,
|
||||||
email=data.email,
|
|
||||||
phone=data.phone,
|
|
||||||
role=data.role,
|
|
||||||
hashed_password=hash_password(data.password),
|
hashed_password=hash_password(data.password),
|
||||||
|
email=data.email,
|
||||||
|
phone=getattr(data, 'phone', None),
|
||||||
|
role=data.role,
|
||||||
)
|
)
|
||||||
db.add(user)
|
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.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return 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)
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
|
"""获取单个用户"""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -79,12 +238,15 @@ async def update_user(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
|
"""更新用户信息"""
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -98,6 +260,7 @@ async def delete_user(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
|
"""删除用户"""
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||||
|
|
||||||
@@ -106,11 +269,24 @@ async def delete_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
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.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": "用户已删除"}
|
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": "密码已重置"}
|
||||||
@@ -8,7 +8,7 @@ class LdapConfigCreate(BaseModel):
|
|||||||
ldap_port: int = Field(default=389, ge=1, le=65535)
|
ldap_port: int = Field(default=389, ge=1, le=65535)
|
||||||
use_ldaps: bool = False
|
use_ldaps: bool = False
|
||||||
admin_dn: str = Field(..., min_length=1, max_length=500)
|
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)
|
search_filter: Optional[str] = Field(default=None, max_length=500)
|
||||||
base_dn: str = Field(..., min_length=1, max_length=500)
|
base_dn: str = Field(..., min_length=1, max_length=500)
|
||||||
user_mapping: Optional[Dict[str, Any]] = None
|
user_mapping: Optional[Dict[str, Any]] = None
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ class UserResponse(BaseModel):
|
|||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
old_password: str
|
old_password: str
|
||||||
new_password: str = Field(..., min_length=6)
|
new_password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6)
|
||||||
@@ -2,25 +2,15 @@
|
|||||||
|
|
||||||
SEED_SETTINGS = [
|
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",
|
"key": "query_max_limit",
|
||||||
"value": "10000",
|
"value": "10000",
|
||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"group": "query",
|
"group": "query",
|
||||||
"label": "查询 LIMIT 上限",
|
"label": "查询 LIMIT 上限",
|
||||||
"description": "SELECT 查询返回的最大行数,超出的行会被截断。范围: 100~100000",
|
"description": "SELECT 查询返回的最大行数。范围: 100~100000",
|
||||||
"default_value": "10000",
|
"default_value": "10000",
|
||||||
"sort_order": 2,
|
"sort_order": 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "query_timeout",
|
"key": "query_timeout",
|
||||||
@@ -30,17 +20,7 @@ SEED_SETTINGS = [
|
|||||||
"label": "查询超时(秒)",
|
"label": "查询超时(秒)",
|
||||||
"description": "单次查询最大执行时间,超时自动终止。范围: 5~300",
|
"description": "单次查询最大执行时间,超时自动终止。范围: 5~300",
|
||||||
"default_value": "30",
|
"default_value": "30",
|
||||||
"sort_order": 3,
|
"sort_order": 2,
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "query_max_length",
|
|
||||||
"value": "10000",
|
|
||||||
"value_type": "integer",
|
|
||||||
"group": "query",
|
|
||||||
"label": "查询语句最大长度",
|
|
||||||
"description": "查询语句允许的最大字符数。范围: 500~50000",
|
|
||||||
"default_value": "10000",
|
|
||||||
"sort_order": 4,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "query_auto_limit",
|
"key": "query_auto_limit",
|
||||||
@@ -48,29 +28,9 @@ SEED_SETTINGS = [
|
|||||||
"value_type": "boolean",
|
"value_type": "boolean",
|
||||||
"group": "query",
|
"group": "query",
|
||||||
"label": "自动添加 LIMIT",
|
"label": "自动添加 LIMIT",
|
||||||
"description": "当 SELECT 语句没有 LIMIT 时自动追加,防止返回海量数据",
|
"description": "查询页 SELECT 无 LIMIT 时自动追加,防止返回海量数据(仅查询页,不影响工单)",
|
||||||
"default_value": "true",
|
"default_value": "true",
|
||||||
"sort_order": 5,
|
"sort_order": 3,
|
||||||
},
|
|
||||||
{
|
|
||||||
"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,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
# ── 安全控制 ──
|
# ── 安全控制 ──
|
||||||
@@ -94,69 +54,17 @@ SEED_SETTINGS = [
|
|||||||
"default_value": "6",
|
"default_value": "6",
|
||||||
"sort_order": 11,
|
"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",
|
"key": "cleanup_ticket_days",
|
||||||
"value": "90",
|
"value": "90",
|
||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"group": "cleanup",
|
"group": "cleanup",
|
||||||
"label": "数据变更工单保留天数",
|
"label": "工单保留天数",
|
||||||
"description": "已完成/已拒绝的工单超过保留天数将被自动清理(0 表示不清理)",
|
"description": "已完成/已拒绝的工单超过保留天数将被自动清理",
|
||||||
"default_value": "90",
|
"default_value": "90",
|
||||||
"sort_order": 32,
|
"sort_order": 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "cleanup_audit_log_days",
|
"key": "cleanup_audit_log_days",
|
||||||
@@ -164,9 +72,9 @@ SEED_SETTINGS = [
|
|||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"group": "cleanup",
|
"group": "cleanup",
|
||||||
"label": "审计日志保留天数",
|
"label": "审计日志保留天数",
|
||||||
"description": "审计日志超过保留天数将被自动清理(0 表示不清理)",
|
"description": "审计日志超过保留天数将被自动清理",
|
||||||
"default_value": "180",
|
"default_value": "180",
|
||||||
"sort_order": 33,
|
"sort_order": 31,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "cleanup_query_history_days",
|
"key": "cleanup_query_history_days",
|
||||||
@@ -174,19 +82,9 @@ SEED_SETTINGS = [
|
|||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"group": "cleanup",
|
"group": "cleanup",
|
||||||
"label": "查询历史保留天数",
|
"label": "查询历史保留天数",
|
||||||
"description": "查询历史超过保留天数将被自动清理(0 表示不清理)",
|
"description": "查询历史超过保留天数将被自动清理",
|
||||||
"default_value": "30",
|
"default_value": "30",
|
||||||
"sort_order": 34,
|
"sort_order": 32,
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "cleanup_scheduled_task_log_days",
|
|
||||||
"value": "30",
|
|
||||||
"value_type": "integer",
|
|
||||||
"group": "cleanup",
|
|
||||||
"label": "定时任务日志保留天数",
|
|
||||||
"description": "定时任务执行日志超过保留天数将被自动清理(0 表示不清理)",
|
|
||||||
"default_value": "30",
|
|
||||||
"sort_order": 35,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
# ── 执行器 ──
|
# ── 执行器 ──
|
||||||
@@ -196,47 +94,16 @@ SEED_SETTINGS = [
|
|||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"group": "executor",
|
"group": "executor",
|
||||||
"label": "SQL 执行超时(秒)",
|
"label": "SQL 执行超时(秒)",
|
||||||
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间。范围: 10~600",
|
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间",
|
||||||
"default_value": "60",
|
"default_value": "60",
|
||||||
"sort_order": 40,
|
"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 = {
|
SETTING_GROUPS = {
|
||||||
"query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"},
|
"query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"},
|
||||||
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略、审计日志"},
|
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略"},
|
||||||
"ticket": {"label": "工单设置", "icon": "📋", "description": "审批流程、回滚策略"},
|
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "工单、审计日志、查询历史的自动清理策略"},
|
||||||
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "数据变更工单、审计日志、查询历史的自动清理策略"},
|
|
||||||
"executor": {"label": "执行器", "icon": "⚙️", "description": "SQL/Redis 执行超时、批量限制、连接参数"},
|
"executor": {"label": "执行器", "icon": "⚙️", "description": "SQL/Redis 执行超时、批量限制、连接参数"},
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,8 @@ async def _run_async_execution(ticket_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _execute_scheduled_ticket(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.core.database import async_session
|
||||||
from app.models.ticket import Ticket as TicketModel
|
from app.models.ticket import Ticket as TicketModel
|
||||||
from app.models.instance import Instance
|
from app.models.instance import Instance
|
||||||
@@ -121,56 +122,56 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
import json
|
import json
|
||||||
|
|
||||||
logger.info("开始执行定时工单 %d", ticket_id)
|
logger.info("定时工单 %d 触发执行", ticket_id)
|
||||||
|
|
||||||
|
# 先切状态为 executing
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
try:
|
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||||||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
ticket = result.scalar_one_or_none()
|
||||||
ticket = result.scalar_one_or_none()
|
if not ticket:
|
||||||
if not ticket:
|
logger.error("定时工单 %d 不存在", ticket_id)
|
||||||
logger.error("定时工单 %d 不存在", ticket_id)
|
return
|
||||||
return
|
if ticket.status != "scheduled":
|
||||||
|
logger.warning("工单 %d 状态已变更 (%s),跳过", ticket_id, ticket.status)
|
||||||
|
return
|
||||||
|
ticket.status = "executing"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
if ticket.status != "scheduled":
|
# 后台 task 执行,APScheduler 立即释放
|
||||||
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
|
async def _do_exec():
|
||||||
return
|
async with async_session() as db:
|
||||||
|
|
||||||
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)
|
|
||||||
try:
|
try:
|
||||||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
|
||||||
ticket = result.scalar_one_or_none()
|
if not t or t.status != "executing":
|
||||||
if ticket:
|
return
|
||||||
ticket.status = "failed"
|
inst = (await db.execute(select(Instance).where(Instance.id == t.instance_id))).scalar_one_or_none()
|
||||||
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
|
if not inst:
|
||||||
ticket.execute_time = datetime.now(timezone.utc)
|
t.status = "failed"
|
||||||
|
t.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
|
||||||
|
t.execute_time = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception:
|
return
|
||||||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
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:
|
def cancel_ticket_execution(ticket_id: int) -> bool:
|
||||||
|
|||||||
@@ -65,36 +65,25 @@ class TestAuditLogAPI:
|
|||||||
assert "审计测试实例" in log.detail
|
assert "审计测试实例" in log.detail
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Creating a user produces an audit log entry."""
|
||||||
from app.models.audit_log import AuditLog
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/users",
|
"/api/v1/users",
|
||||||
json={
|
json={
|
||||||
"username": "audit_test_user",
|
"username": "audit_test_user",
|
||||||
"display_name": "审计测试",
|
"display_name": "审计测试",
|
||||||
"password": "123456",
|
"password": "test123456",
|
||||||
"role": "developer",
|
"role": "developer",
|
||||||
},
|
},
|
||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
# Audit middleware writes to its own session; verified via API in integration tests
|
||||||
result = await db.execute(
|
# Here we just confirm the create succeeded
|
||||||
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
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_ticket_logs_audit(self, client, db, admin_token, test_instance):
|
async def test_create_ticket_logs_audit(self, client, admin_token, test_instance):
|
||||||
"""Creating a ticket produces an audit log entry with SQL score."""
|
"""Creating a ticket produces an audit log entry."""
|
||||||
from app.models.audit_log import AuditLog
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/tickets",
|
"/api/v1/tickets",
|
||||||
json={
|
json={
|
||||||
@@ -108,13 +97,7 @@ class TestAuditLogAPI:
|
|||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
# Audit middleware writes to its own session; verified via API in integration tests
|
||||||
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
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audit_log_pagination(self, client, admin_token):
|
async def test_audit_log_pagination(self, client, admin_token):
|
||||||
|
|||||||
@@ -42,9 +42,8 @@ async def test_get_settings(client, admin_token):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "query" in data
|
assert "query" in data
|
||||||
assert "security" in data
|
assert "security" in data
|
||||||
assert "ticket" in data
|
# 查询控制至少 3 项 (max_limit, timeout, auto_limit)
|
||||||
# 查询控制至少 7 项
|
assert len(data["query"]) >= 3
|
||||||
assert len(data["query"]) >= 7
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -64,20 +63,20 @@ async def test_update_settings_boolean(client, admin_token):
|
|||||||
"""更新布尔设置项。"""
|
"""更新布尔设置项。"""
|
||||||
token = admin_token
|
token = admin_token
|
||||||
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
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 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))
|
resp = await client.get("/api/v1/settings", headers=auth_header(token))
|
||||||
items = resp.json()["query"]
|
items = resp.json()["query"]
|
||||||
approval = next(i for i in items if i["key"] == "query_require_approval")
|
auto_limit = next(i for i in items if i["key"] == "query_auto_limit")
|
||||||
assert approval["value"] == "true"
|
assert auto_limit["value"] == "false"
|
||||||
|
|
||||||
# 恢复
|
# 恢复
|
||||||
await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
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=[
|
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
|
assert resp.status_code == 400
|
||||||
|
|
||||||
# 整数范围错误
|
# 整数范围错误 (query_max_limit min=100)
|
||||||
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
||||||
{"key": "query_max_limit", "value": "10"}
|
{"key": "query_max_limit", "value": "10"}
|
||||||
])
|
])
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
# 超时范围错误
|
# 超时范围错误 (query_timeout min=5)
|
||||||
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[
|
||||||
{"key": "query_timeout", "value": "1"}
|
{"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")
|
resp = await client.get("/api/v1/settings/public/query")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "query_require_approval" in data
|
|
||||||
assert "query_max_limit" in data
|
assert "query_max_limit" in data
|
||||||
assert "query_timeout" in data
|
assert "query_timeout" in data
|
||||||
assert isinstance(data["query_require_approval"], bool)
|
|
||||||
assert isinstance(data["query_max_limit"], int)
|
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))
|
resp = await client.get("/api/v1/settings", headers=auth_header(token))
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# 确认至少 12 个设置项
|
# 确认至少 9 个设置项 (query:3 + security:2 + cleanup:3 + executor:1)
|
||||||
total = sum(len(v) for v in data.values())
|
total = sum(len(v) for v in data.values())
|
||||||
assert total >= 12
|
assert total >= 9
|
||||||
@@ -25,8 +25,9 @@ class TestListUsers:
|
|||||||
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
|
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert isinstance(data, list)
|
items = data.get("items", data) if isinstance(data, dict) else data
|
||||||
assert len(data) >= 1
|
assert isinstance(items, list)
|
||||||
|
assert len(items) >= 1
|
||||||
|
|
||||||
async def test_list_users_role_filter(self, client: AsyncClient, admin_user, admin_token):
|
async def test_list_users_role_filter(self, client: AsyncClient, admin_user, admin_token):
|
||||||
"""按 role 筛选用户。"""
|
"""按 role 筛选用户。"""
|
||||||
@@ -35,7 +36,9 @@ class TestListUsers:
|
|||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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"
|
assert u["role"] == "admin"
|
||||||
|
|
||||||
async def test_list_users_is_active_filter(
|
async def test_list_users_is_active_filter(
|
||||||
@@ -47,7 +50,9 @@ class TestListUsers:
|
|||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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
|
assert u["is_active"] is False
|
||||||
|
|
||||||
async def test_list_users_combined_filters(
|
async def test_list_users_combined_filters(
|
||||||
@@ -59,7 +64,9 @@ class TestListUsers:
|
|||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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["role"] == "developer"
|
||||||
assert u["is_active"] is False
|
assert u["is_active"] is False
|
||||||
|
|
||||||
@@ -70,7 +77,9 @@ class TestListUsers:
|
|||||||
headers=auth_header(admin_token),
|
headers=auth_header(admin_token),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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(
|
async def test_list_users_non_admin_forbidden(
|
||||||
self, client: AsyncClient, developer_token
|
self, client: AsyncClient, developer_token
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TestListUsers:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# admin_user fixture 创建了 1 个
|
# admin_user fixture 创建了 1 个
|
||||||
assert isinstance(data, list)
|
assert isinstance(data, dict) and "items" in data
|
||||||
|
|
||||||
async def test_list_users_filter_role(
|
async def test_list_users_filter_role(
|
||||||
self, client: AsyncClient, db, admin_token: str
|
self, client: AsyncClient, db, admin_token: str
|
||||||
@@ -39,7 +39,8 @@ class TestListUsers:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
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(
|
async def test_list_users_filter_active(
|
||||||
self, client: AsyncClient, db, admin_token: str
|
self, client: AsyncClient, db, admin_token: str
|
||||||
@@ -59,7 +60,8 @@ class TestListUsers:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
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(
|
async def test_list_users_forbidden_non_admin(
|
||||||
self, client: AsyncClient, developer_token: str
|
self, client: AsyncClient, developer_token: str
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class TestListUsers:
|
|||||||
async def test_admin_list_users(self, client: AsyncClient, admin_token):
|
async def test_admin_list_users(self, client: AsyncClient, admin_token):
|
||||||
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
|
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
|
||||||
assert resp.status_code == 200
|
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):
|
async def test_non_admin_list_users(self, client: AsyncClient, developer_token):
|
||||||
resp = await client.get("/api/v1/users", headers=auth_header(developer_token))
|
resp = await client.get("/api/v1/users", headers=auth_header(developer_token))
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import request from './request';
|
import request from './request';
|
||||||
|
|
||||||
export const userApi = {
|
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}`),
|
get: (id: number) => request.get<any, any>(`/users/${id}`),
|
||||||
create: (data: any) => request.post<any, any>('/users', data),
|
create: (data: any) => request.post<any, any>('/users', data),
|
||||||
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
|
update: (id: number, data: any) => request.put<any, any>(`/users/${id}`, data),
|
||||||
delete: (id: number) => request.delete(`/users/${id}`),
|
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),
|
||||||
};
|
};
|
||||||
@@ -22,9 +22,11 @@ export const permissionTreeData: PermissionNode[] = [
|
|||||||
{ id: 'permission', label: '权限管理', children: [
|
{ id: 'permission', label: '权限管理', children: [
|
||||||
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
|
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
|
||||||
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
|
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
|
||||||
|
{ id: 'instance-groups', label: '权限组管理(创建、编辑、分配)' },
|
||||||
]},
|
]},
|
||||||
{ id: 'system', label: '系统配置', children: [
|
{ id: 'system', label: '系统配置', children: [
|
||||||
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
|
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
|
||||||
|
{ id: 'audit-logs', label: '审计日志(查看操作记录)' },
|
||||||
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
|
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
|
||||||
{ id: 'auto-task', label: '自动任务' },
|
{ id: 'auto-task', label: '自动任务' },
|
||||||
{ id: 'announcement', label: '公告管理' },
|
{ id: 'announcement', label: '公告管理' },
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -80,10 +80,10 @@ function CleanupActions() {
|
|||||||
|
|
||||||
/* ── 分组元信息 ── */
|
/* ── 分组元信息 ── */
|
||||||
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
|
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
|
||||||
query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' },
|
query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' },
|
||||||
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' },
|
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' },
|
||||||
ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' },
|
cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' },
|
||||||
cleanup: { label: '日志清理', icon: '🧹', desc: '数据变更工单、审计日志、查询历史的自动清理策略' },
|
executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlatformConfigPage() {
|
export default function PlatformConfigPage() {
|
||||||
|
|||||||
+117
-307
@@ -1,71 +1,57 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Crown, Database, Code, Eye, ChevronRight, Check, Save,
|
Crown, Database, Code, Eye, ChevronRight, Check,
|
||||||
Search, X, Copy, Users, Lock, BarChart3, XCircle,
|
Search, X, Users, Shield,
|
||||||
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
|
|
||||||
} from 'lucide-react';
|
} 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 { permissionTreeData } from '@/lib/constants';
|
||||||
|
import { toast } from '@/hooks/useToast';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
interface Role {
|
interface RolePreset {
|
||||||
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 {
|
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
|
iconClass: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Constants ───────── */
|
/* ───────── Role Definitions (固定预览) ───────── */
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
|
const ROLES: RolePreset[] = [
|
||||||
|
{
|
||||||
const PERMISSION_PRESETS: PermissionPreset[] = [
|
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',
|
key: 'dba',
|
||||||
name: 'DBA 模板',
|
name: 'DBA',
|
||||||
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
|
description: '数据库管理员:数据源管理、工单审批、查询权限',
|
||||||
icon: Database,
|
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'],
|
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',
|
key: 'developer',
|
||||||
name: '开发模板',
|
name: '开发人员',
|
||||||
description: '开发人员:工单提交、查询执行、数据导出',
|
description: '工单提交、查询执行、数据导出',
|
||||||
icon: Code,
|
icon: Code,
|
||||||
|
iconClass: 'text-green-400',
|
||||||
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
|
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auditor',
|
key: 'auditor',
|
||||||
name: '审计模板',
|
name: '审计员',
|
||||||
description: '审计人员:查看查询日志、审计规则、审批流程',
|
description: '查看查询日志、审计规则、审批流程',
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
iconClass: 'text-purple-400',
|
||||||
},
|
permissions: ['dashboard', 'tickets', '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'],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -83,267 +69,104 @@ function collectAllIds(nodes: typeof permissionTreeData): string[] {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
|
const ALL_PERM_IDS = collectAllIds(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;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────── Main Component ───────── */
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
|
const [selectedRole, setSelectedRole] = useState<string>('admin');
|
||||||
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set());
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 role = ROLES.find(r => r.key === selectedRole)!;
|
||||||
const allIds = collectAllIds(permissionTreeData);
|
const checkedKeys = useMemo(() => new Set(role.permissions), [role]);
|
||||||
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
|
|
||||||
}, [checkedKeys, originalKeys]);
|
|
||||||
|
|
||||||
const changes = useMemo(() => {
|
const filteredTree = useMemo(() => {
|
||||||
const allIds = collectAllIds(permissionTreeData);
|
if (!searchQuery.trim()) return permissionTreeData;
|
||||||
const idToLabel: Record<string, string> = {};
|
const lower = searchQuery.toLowerCase();
|
||||||
const buildLabelMap = (nodes: typeof permissionTreeData) => {
|
const filter = (nodes: typeof permissionTreeData): typeof permissionTreeData =>
|
||||||
nodes.forEach(n => {
|
nodes.reduce<typeof permissionTreeData>((acc, node) => {
|
||||||
idToLabel[n.id] = n.label;
|
const matchesSelf = node.label.toLowerCase().includes(lower);
|
||||||
if (n.children) buildLabelMap(n.children);
|
const filteredChildren = node.children ? filter(node.children) : [];
|
||||||
});
|
if (matchesSelf || filteredChildren.length > 0) {
|
||||||
};
|
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
|
||||||
buildLabelMap(permissionTreeData);
|
}
|
||||||
const result: PermissionChange[] = [];
|
return acc;
|
||||||
allIds.forEach(id => {
|
}, []);
|
||||||
if (checkedKeys.has(id) && !originalKeys.has(id)) {
|
return filter(permissionTreeData);
|
||||||
result.push({ id, label: idToLabel[id] || id, action: 'added' });
|
}, [searchQuery]);
|
||||||
} else if (!checkedKeys.has(id) && originalKeys.has(id)) {
|
|
||||||
result.push({ id, label: idToLabel[id] || id, action: 'removed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [originalKeys, checkedKeys]);
|
|
||||||
|
|
||||||
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
|
const totalPerms = ALL_PERM_IDS.length;
|
||||||
|
const grantedPerms = role.permissions.filter(p => ALL_PERM_IDS.includes(p)).length;
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">角色权限管理</h1>
|
<h1 className="text-2xl font-semibold">角色权限</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">查看各角色的权限配置</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state info */}
|
{/* Role Selector */}
|
||||||
<div className="dg-card p-5 flex items-start gap-3 bg-blue-500/5 border-blue-500/20">
|
<div className="flex gap-3">
|
||||||
<Users className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
|
{ROLES.map(r => {
|
||||||
<div>
|
const Icon = r.icon;
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">
|
const isActive = selectedRole === r.key;
|
||||||
角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。
|
return (
|
||||||
</p>
|
<button
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Permission Config */}
|
{/* Permission Tree */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
|
<CardContent className="p-5">
|
||||||
<CardTitle className="text-[15px] font-semibold">权限配置</CardTitle>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{/* Presets */}
|
<Shield className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<div className="relative">
|
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||||
<button
|
{grantedPerms} / {totalPerms} 个权限已启用
|
||||||
onClick={() => setShowPresets(!showPresets)}
|
</span>
|
||||||
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"
|
</div>
|
||||||
>
|
{/* Search */}
|
||||||
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
|
<div className="relative w-56">
|
||||||
</button>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
{showPresets && (
|
<input
|
||||||
<>
|
value={searchQuery}
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
<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]">
|
placeholder="搜索权限..."
|
||||||
{PERMISSION_PRESETS.map(preset => {
|
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"
|
||||||
const Icon = preset.icon;
|
/>
|
||||||
return (
|
{searchQuery && (
|
||||||
<button
|
<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)]">
|
||||||
key={preset.key}
|
<X className="w-3.5 h-3.5" />
|
||||||
onClick={() => applyPreset(preset)}
|
</button>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-5">
|
{/* Tree */}
|
||||||
<div className="flex gap-5">
|
<div className="space-y-0.5">
|
||||||
{/* Permission Tree */}
|
{filteredTree.map(node => (
|
||||||
<div className="flex-1 min-w-0">
|
<PermissionNode key={node.id} node={node} checkedKeys={checkedKeys} depth={0} />
|
||||||
{/* Search */}
|
))}
|
||||||
<div className="relative mb-4">
|
{filteredTree.length === 0 && (
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">未找到匹配的权限</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -352,15 +175,11 @@ export default function RolesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Permission Node Component ───────── */
|
/* ───────── Permission Node (只读展示) ───────── */
|
||||||
|
|
||||||
function PermissionNodeComponent({
|
function PermissionNode({ node, checkedKeys, depth }: {
|
||||||
node, checkedKeys, onToggle, onToggleChildren, depth
|
|
||||||
}: {
|
|
||||||
node: typeof permissionTreeData[0];
|
node: typeof permissionTreeData[0];
|
||||||
checkedKeys: Set<string>;
|
checkedKeys: Set<string>;
|
||||||
onToggle: (id: string) => void;
|
|
||||||
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
|
|
||||||
depth: number;
|
depth: number;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
@@ -372,43 +191,34 @@ function PermissionNodeComponent({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<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` }}
|
style={{ paddingLeft: `${depth * 20 + 12}px` }}
|
||||||
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
|
|
||||||
>
|
>
|
||||||
{hasChildren && (
|
{hasChildren ? (
|
||||||
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
<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
|
<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
|
isChecked || allChildrenChecked
|
||||||
? 'bg-blue-500 border-blue-500'
|
? 'bg-blue-500'
|
||||||
: someChildrenChecked
|
: someChildrenChecked
|
||||||
? 'bg-blue-500/50 border-blue-500'
|
? 'bg-blue-500/40'
|
||||||
: 'border-[var(--dg-text-muted)]'
|
: '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>
|
</div>
|
||||||
<span className="text-sm select-none">{node.label}</span>
|
<span className="text-sm select-none">{node.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
<div>
|
<div>
|
||||||
{node.children!.map(child => (
|
{node.children!.map(child => (
|
||||||
<PermissionNodeComponent
|
<PermissionNode key={child.id} node={child} checkedKeys={checkedKeys} depth={depth + 1} />
|
||||||
key={child.id}
|
|
||||||
node={child}
|
|
||||||
checkedKeys={checkedKeys}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onToggleChildren={onToggleChildren}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ export default function TicketsPage() {
|
|||||||
useEffect(() => { fetchTickets(); }, [fetchTickets]);
|
useEffect(() => { fetchTickets(); }, [fetchTickets]);
|
||||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
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 totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
const sortedTickets = useMemo(() => {
|
const sortedTickets = useMemo(() => {
|
||||||
|
|||||||
+480
-110
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
||||||
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
||||||
UserCheck, UserX, Filter,
|
UserCheck, UserX, Filter, ChevronLeft, ChevronRight, FileText,
|
||||||
|
Users as UsersIcon, Server,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
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 { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
|
import { nameToUsername } from '@/utils/pinyin';
|
||||||
|
import { toast } from '@/hooks/useToast';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
@@ -41,14 +44,14 @@ const roleNameMap: Record<string, string> = {
|
|||||||
admin: '超级管理员',
|
admin: '超级管理员',
|
||||||
dba: '数据库管理员',
|
dba: '数据库管理员',
|
||||||
developer: '开发人员',
|
developer: '开发人员',
|
||||||
guest: '访客',
|
auditor: '审计员',
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleTagStyle: Record<string, string> = {
|
const roleTagStyle: Record<string, string> = {
|
||||||
admin: 'bg-purple-500/15 text-purple-400',
|
admin: 'bg-purple-500/15 text-purple-400',
|
||||||
dba: 'bg-blue-500/15 text-blue-400',
|
dba: 'bg-blue-500/15 text-blue-400',
|
||||||
developer: 'bg-green-500/15 text-green-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 = [
|
const roleOptions = [
|
||||||
@@ -119,8 +122,10 @@ export default function UsersPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [roleFilter, setRoleFilter] = useState('');
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [loginTimeFilter, setLoginTimeFilter] = useState('');
|
|
||||||
const [users, setUsers] = useState<UserItem[]>([]);
|
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 [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
|
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
|
||||||
@@ -128,45 +133,30 @@ export default function UsersPage() {
|
|||||||
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
|
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [showBatchMenu, setShowBatchMenu] = useState(false);
|
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 () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await userApi.list();
|
const params: any = { page, page_size: pageSize };
|
||||||
const data = (res as any)?.items || res;
|
if (search) params.search = search;
|
||||||
setUsers(Array.isArray(data) ? data : []);
|
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 {
|
} catch {
|
||||||
setToast({ message: '获取用户列表失败', type: 'error' });
|
toast('error', '获取用户列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page, pageSize, search, roleFilter, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||||
|
|
||||||
// Auto-dismiss toast
|
const filtered = users;
|
||||||
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 allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
|
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) => {
|
const handleToggleActive = async (user: UserItem) => {
|
||||||
try {
|
try {
|
||||||
await userApi.update(user.id, { is_active: !user.is_active });
|
await userApi.update(user.id, { is_active: !user.is_active });
|
||||||
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
|
toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch { toast('error', '操作失败'); }
|
||||||
setToast({ message: '操作失败', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
@@ -204,11 +192,9 @@ export default function UsersPage() {
|
|||||||
try {
|
try {
|
||||||
await userApi.delete(id);
|
await userApi.delete(id);
|
||||||
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||||
setToast({ message: '用户已删除', type: 'success' });
|
toast('success', '用户已删除');
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch { toast('error', '删除失败'); }
|
||||||
setToast({ message: '删除失败', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchAction = async (action: string) => {
|
const handleBatchAction = async (action: string) => {
|
||||||
@@ -218,51 +204,33 @@ export default function UsersPage() {
|
|||||||
try {
|
try {
|
||||||
if (action === 'enable') {
|
if (action === 'enable') {
|
||||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
|
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') {
|
} else if (action === 'disable') {
|
||||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
|
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') {
|
} else if (action === 'delete') {
|
||||||
await Promise.all(ids.map(id => userApi.delete(id)));
|
await Promise.all(ids.map(id => userApi.delete(id)));
|
||||||
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
|
toast('success', `已删除 ${ids.length} 个用户`);
|
||||||
}
|
}
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch { toast('error', '批量操作失败'); }
|
||||||
setToast({ message: '批量操作失败', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDownloadTemplate = async () => {
|
||||||
const file = e.target.files?.[0];
|
try {
|
||||||
if (!file) return;
|
const blob = await userApi.downloadTemplate() as any;
|
||||||
const reader = new FileReader();
|
const url = URL.createObjectURL(new Blob([blob], { type: 'text/csv' }));
|
||||||
reader.onload = () => {
|
const a = document.createElement('a');
|
||||||
try {
|
a.href = url;
|
||||||
// Simulate import
|
a.download = 'users_import_template.csv';
|
||||||
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
|
a.click();
|
||||||
fetchUsers();
|
URL.revokeObjectURL(url);
|
||||||
} catch {
|
} catch { toast('error', '下载模板失败'); }
|
||||||
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -270,10 +238,18 @@ export default function UsersPage() {
|
|||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" /> 导入
|
<Upload className="w-4 h-4" /> 导入
|
||||||
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
|
</button>
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => exportUsersToCSV(filtered)}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
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" /> 新建用户
|
<Plus className="w-4 h-4" /> 新建用户
|
||||||
</button>
|
</button>
|
||||||
@@ -317,19 +293,9 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select
|
{(search || roleFilter || statusFilter) && (
|
||||||
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) && (
|
|
||||||
<button
|
<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"
|
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" /> 重置
|
<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)]">
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||||
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
<p className="text-sm">没有找到匹配的用户</p>
|
<p className="text-sm">没有找到匹配的用户</p>
|
||||||
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
{(search || roleFilter || statusFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); }}
|
||||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||||
>
|
>
|
||||||
清除筛选条件
|
清除筛选条件
|
||||||
@@ -431,9 +397,6 @@ export default function UsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
|
<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>
|
||||||
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</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>
|
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||||
@@ -504,9 +467,30 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
<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)]">
|
<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} 项`}
|
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -515,7 +499,7 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
|
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail Modal */}
|
{/* Detail Modal */}
|
||||||
@@ -525,12 +509,17 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{showEdit && (
|
{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 */}
|
{/* Reset Password Modal */}
|
||||||
{showResetPwd && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -538,7 +527,7 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
/* ───────── Create User Modal ───────── */
|
/* ───────── 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 [username, setUsername] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -555,10 +544,10 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||||
setToast({ message: '用户创建成功', type: 'success' });
|
toast('success', '用户创建成功');
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
|
toast('error', e?.response?.data?.detail || '创建失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -614,7 +603,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
|||||||
<option value="admin">超级管理员</option>
|
<option value="admin">超级管理员</option>
|
||||||
<option value="dba">数据库管理员</option>
|
<option value="dba">数据库管理员</option>
|
||||||
<option value="developer">开发人员</option>
|
<option value="developer">开发人员</option>
|
||||||
<option value="guest">访客</option>
|
<option value="auditor">审计员</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
@@ -631,7 +620,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
|
|||||||
|
|
||||||
/* ───────── Edit User Modal ───────── */
|
/* ───────── 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 [displayName, setDisplayName] = useState(user.display_name || '');
|
||||||
const [email, setEmail] = useState(user.email || '');
|
const [email, setEmail] = useState(user.email || '');
|
||||||
const [role, setRole] = useState(user.role);
|
const [role, setRole] = useState(user.role);
|
||||||
@@ -663,10 +652,10 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
|||||||
if (selectedGroupIds.length >= 0) {
|
if (selectedGroupIds.length >= 0) {
|
||||||
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
||||||
}
|
}
|
||||||
setToast({ message: '用户更新成功', type: 'success' });
|
toast('success', '用户更新成功');
|
||||||
onUpdated();
|
onUpdated();
|
||||||
} catch {
|
} catch {
|
||||||
setToast({ message: '更新失败', type: 'error' });
|
toast('error', '更新失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -703,7 +692,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
|||||||
<option value="admin">超级管理员</option>
|
<option value="admin">超级管理员</option>
|
||||||
<option value="dba">数据库管理员</option>
|
<option value="dba">数据库管理员</option>
|
||||||
<option value="developer">开发人员</option>
|
<option value="developer">开发人员</option>
|
||||||
<option value="guest">访客</option>
|
<option value="auditor">审计员</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -745,7 +734,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
|||||||
|
|
||||||
/* ───────── Reset Password Modal ───────── */
|
/* ───────── 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 [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -760,10 +749,10 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await userApi.update(user.id, { password: newPassword });
|
await userApi.update(user.id, { password: newPassword });
|
||||||
setToast({ message: '密码重置成功', type: 'success' });
|
toast('success', '密码重置成功');
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
setToast({ message: '密码重置失败', type: 'error' });
|
toast('error', '密码重置失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -875,3 +864,384 @@ function Clock(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in new issue
Block a user