全量 JSX children + toast/confirm + sr-only 描述硬编码中文清零: - Kubernetes模块: UpdateStrategyCard maxSurge/maxUnavailable说明 + IngressCreateForm host域名/Service端口 + ServiceCreateForm 端口配置/selector + DeploymentEditService 无selector/个关联/暂无Service/Ingress + ClusterOverviewPage 核 + KubernetesClusterDialog 已配置 + KubectlTerminal/ResourceVisualEditor/CreateHPADialog/DeploymentEditContainers/Storage + PodInstancesTable/UpdateStrategySelectorCards - AI模块: PredictiveCapacityTab AI深度解读/紧急/警告/7天预测/预计N天 + PredictiveCostTab AI深度解读 + event-handler.ts 高危操作确认toast - Deploy模块: DeployCredentials 启用/禁用 + DeployRBAC 删除角色confirm + WorkspaceTermDialog + CredFormDialog Windows用户 - Monitor模块: AlertRulesTab 删除confirm/分钟 + PrometheusDatasourceManager/LogDatasourcePage 删除confirm + QueryHistoryPanel 条 - System模块: ServerFileBrowser/TerminalDialog/RdpAuditViewer sr-only + SystemManager/CredentialManager/TerminalSessionsTab 删除confirm + SecuritySettingsTab 30分钟/2小时 - Task模块: CronJobViewDialog 次 新增 61 翻译键 (k8s.editAdvanced 6 + k8s.editService 11 + k8s.cluster 2 + k8s.misc 9 + aiops.capacity 9 + aiops.cost 1 + aiops.cli 2 + deploy 4 + monitor 5 + system 10 + task 1 + common 2) tsc -b=0, npm build✓ (17.71s), ESLint=0/43/any=0, go build=0, 17/17 测试通过
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
import { useState, useEffect } from "react";
|
||
import { useLocation } from "react-router-dom";
|
||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { toast } from "sonner";
|
||
import { CheckCircle, XCircle, Settings, Users, Shield } from "lucide-react";
|
||
import { PageShell } from "@/components/layout/PageShell";
|
||
import { userApi } from "@/api";
|
||
import type { Role } from "@/types";
|
||
import { SystemUserTab } from "./SystemUserTab";
|
||
import { SystemRolesTab } from "./SystemRolesTab";
|
||
import { SystemSettingsTab } from "./SystemSettingsTab";
|
||
import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
||
import { useLdapManagement } from "./useLdapManagement";
|
||
import { useAuditSettings } from "./useAuditSettings";
|
||
import type { UserFormData } from "./UserFormDialog";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
// 后端用户数据转换为前端兼容格式
|
||
function mapUser(u: any) {
|
||
return {
|
||
...u,
|
||
status:
|
||
u.status === 1 || u.status === "active"
|
||
? ("active" as const)
|
||
: ("inactive" as const),
|
||
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
|
||
lastLoginAt: u.last_login ?? u.lastLoginAt,
|
||
createdAt: u.created_at ?? u.createdAt,
|
||
authSource: u.auth_source ?? u.authSource ?? "local",
|
||
};
|
||
}
|
||
|
||
const emptyForm: UserFormData = {
|
||
username: "",
|
||
nickname: "",
|
||
email: "",
|
||
phone: "",
|
||
role: "",
|
||
password: "",
|
||
status: 1 as number,
|
||
};
|
||
|
||
export function SystemManager() {
|
||
const { t } = useTranslation();
|
||
const confirm = useConfirm();
|
||
const location = useLocation();
|
||
// 侧边栏菜单路径映射到 Tab // Logan.Li
|
||
// 侧边栏已有独立菜单项,按路由直接渲染对应页面,不再重复 Tab // Logan.Li
|
||
const pathToTab: Record<string, "users" | "roles" | "settings"> = {
|
||
"/users": "users",
|
||
"/roles": "roles",
|
||
"/settings": "settings",
|
||
};
|
||
const activeTab = pathToTab[location.pathname] || "users";
|
||
|
||
const pageMeta: Record<
|
||
string,
|
||
{ title: string; subtitle: string; icon: React.ReactNode }
|
||
> = {
|
||
users: {
|
||
title: t("system.users"),
|
||
subtitle: t("system.localUserDesc"),
|
||
icon: <Users className="h-5 w-5" />,
|
||
},
|
||
roles: {
|
||
title: t("system.roles"),
|
||
subtitle: t("system.roleBindingHint"),
|
||
icon: <Shield className="h-5 w-5" />,
|
||
},
|
||
settings: {
|
||
title: t("system.settings"),
|
||
subtitle: t("system.generalSubtitle"),
|
||
icon: <Settings className="h-5 w-5" />,
|
||
},
|
||
};
|
||
const meta = pageMeta[activeTab];
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [users, setUsers] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedUser, setSelectedUser] = useState<any | null>(null);
|
||
const [resetPasswordUser, setResetPasswordUser] = useState<any | null>(null);
|
||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||
|
||
// 创建/编辑用户弹窗
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [form, setForm] = useState<UserFormData>({ ...emptyForm });
|
||
|
||
// CSV 批量导入弹窗
|
||
const [importDialogOpen, setImportDialogOpen] = useState<
|
||
"csv" | "ldap" | null
|
||
>(null);
|
||
const [importPreview, setImportPreview] = useState<any[]>([]);
|
||
const [importResult, setImportResult] = useState<any>(null);
|
||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||
|
||
const loadUsers = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await userApi.getUsers({ page: 1, page_size: 100 });
|
||
const rawUsers = res?.list ?? [];
|
||
setUsers(rawUsers.map(mapUser));
|
||
} catch (error) {
|
||
console.error("Failed to fetch users:", error);
|
||
setUsers([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// LDAP 状态全部由 hook 管理(在 loadUsers 之后调用避免 TDZ) // Logan.Li
|
||
const ldap = useLdapManagement(loadUsers);
|
||
const audit = useAuditSettings();
|
||
|
||
useEffect(() => {
|
||
loadUsers();
|
||
ldap.loadLdapConfig();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// LDAP 导入 Dialog 打开时加载 OU 和用户
|
||
useEffect(() => {
|
||
if (importDialogOpen === "ldap") {
|
||
ldap.loadImportDialogData();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [importDialogOpen]);
|
||
|
||
const filteredUsers = users.filter(
|
||
(u) =>
|
||
(u.username || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
(u.nickname || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
(u.email || "").toLowerCase().includes(searchQuery.toLowerCase()),
|
||
);
|
||
|
||
const getUserStatusBadge = (status: "active" | "inactive") => {
|
||
return status === "active" ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10"
|
||
>
|
||
<CheckCircle className="h-3 w-3 mr-1" />
|
||
{t("common.normal")}
|
||
</Badge>
|
||
) : (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10"
|
||
>
|
||
<XCircle className="h-3 w-3 mr-1" />
|
||
{t("system.userInactive")}
|
||
</Badge>
|
||
);
|
||
};
|
||
|
||
const openCreate = () => {
|
||
setEditingUser(null);
|
||
setForm({ ...emptyForm });
|
||
setDialogOpen(true);
|
||
};
|
||
|
||
const openEdit = (user: any) => {
|
||
setEditingUser(user);
|
||
setForm({
|
||
username: user.username || "",
|
||
nickname: user.nickname || "",
|
||
email: user.email || "",
|
||
phone: user.phone || "",
|
||
role: Array.isArray(user.roles) ? user.roles[0] || "" : user.role || "",
|
||
password: "",
|
||
status: user.status === "active" ? 1 : 0,
|
||
});
|
||
setDialogOpen(true);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
username: form.username,
|
||
nickname: form.nickname,
|
||
email: form.email,
|
||
phone: form.phone,
|
||
role: form.role,
|
||
status: form.status,
|
||
};
|
||
if (form.password) {
|
||
payload.password = form.password;
|
||
}
|
||
if (editingUser) {
|
||
await userApi.updateUser(editingUser.id, payload);
|
||
} else {
|
||
await userApi.createUser(payload);
|
||
}
|
||
setDialogOpen(false);
|
||
await loadUsers();
|
||
} catch (error) {
|
||
console.error("Failed to save user:", error);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (user: any) => {
|
||
const ok = await confirm({
|
||
title: t("system.deleteUser"),
|
||
description: t('system.deleteUserConfirmDesc', { name: user.nickname || user.username }),
|
||
confirmText: t("common.delete"),
|
||
variant: "destructive",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await userApi.deleteUser(user.id);
|
||
setSelectedUserIds((prev) => prev.filter((id) => id !== user.id));
|
||
await loadUsers();
|
||
toast.success(t("system.userDeleted"));
|
||
} catch (error: unknown) {
|
||
toast.error(
|
||
error instanceof Error ? error.message : t("common.deleteFailed"),
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (selectedUserIds.length === 0) return;
|
||
const ok = await confirm({
|
||
title: t("system.batchDeleteUsers"),
|
||
description: t("system.batchDeleteUsersConfirmDesc", { count: selectedUserIds.length }),
|
||
confirmText: t("system.terminalBatchDelete"),
|
||
variant: "destructive",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const res = (await userApi.batchDelete(selectedUserIds)) as {
|
||
message?: string;
|
||
};
|
||
toast.success(res?.message || t('system.batchDeleteUsers', { count: selectedUserIds.length }));
|
||
setSelectedUserIds([]);
|
||
await loadUsers();
|
||
} catch (error: unknown) {
|
||
toast.error(
|
||
error instanceof Error ? error.message : t("cloud.batchDeleteFailed"),
|
||
);
|
||
}
|
||
};
|
||
|
||
// CSV 文件解析
|
||
const handleCsvUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
const text = ev.target?.result as string;
|
||
const lines = text
|
||
.split("\n")
|
||
.filter((l) => l.trim() && !l.startsWith("\xEF\xBB\xBF"));
|
||
if (lines.length < 2) return;
|
||
// Find column indices from header
|
||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||
const idxUsername = headers.findIndex(
|
||
(h) => h.includes("用户名") || h.includes("username"),
|
||
);
|
||
const idxNickname = headers.findIndex(
|
||
(h) => h.includes("昵称") || h.includes("name") || h.includes("显示名"),
|
||
);
|
||
const idxEmail = headers.findIndex(
|
||
(h) => h.includes("邮箱") || h.includes("email") || h.includes("mail"),
|
||
);
|
||
const idxPhone = headers.findIndex(
|
||
(h) => h.includes("手机") || h.includes("phone"),
|
||
);
|
||
const idxRole = headers.findIndex(
|
||
(h) => h.includes("角色") || h.includes("role"),
|
||
);
|
||
const idxPassword = headers.findIndex(
|
||
(h) => h.includes("密码") || h.includes("password"),
|
||
);
|
||
|
||
const parsed: Record<string, unknown>[] = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const cols = lines[i].split(",").map((c) => c.trim());
|
||
parsed.push({
|
||
username: idxUsername >= 0 ? cols[idxUsername] : "",
|
||
nickname: idxNickname >= 0 ? cols[idxNickname] : "",
|
||
email: idxEmail >= 0 ? cols[idxEmail] : "",
|
||
phone: idxPhone >= 0 ? cols[idxPhone] : "",
|
||
role: idxRole >= 0 ? cols[idxRole] : "",
|
||
password: idxPassword >= 0 ? cols[idxPassword] : "",
|
||
});
|
||
}
|
||
setImportPreview(parsed);
|
||
setImportResult(null);
|
||
};
|
||
reader.readAsText(file, "UTF-8");
|
||
};
|
||
|
||
// 执行 CSV 批量导入
|
||
const handleBatchImport = async () => {
|
||
ldap.setImporting(true);
|
||
try {
|
||
const res = await userApi.batchImport({
|
||
users: importPreview,
|
||
skip_existing: true,
|
||
});
|
||
setImportResult(res);
|
||
await loadUsers();
|
||
} catch (error) {
|
||
setImportResult({
|
||
code: 500,
|
||
message: error?.message || t("system.importFailed"),
|
||
});
|
||
} finally {
|
||
ldap.setImporting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<PageShell
|
||
variant={activeTab === "settings" ? "config" : "list"}
|
||
maxWidth={activeTab === "settings" ? "xl" : "full"}
|
||
title={meta.title}
|
||
subtitle={meta.subtitle}
|
||
icon={meta.icon}
|
||
>
|
||
<div className="w-full">
|
||
{activeTab === "users" && (
|
||
<div className="space-y-4">
|
||
<SystemUserTab
|
||
searchQuery={searchQuery}
|
||
setSearchQuery={setSearchQuery}
|
||
filteredUsers={filteredUsers}
|
||
loading={loading}
|
||
selectedUser={selectedUser}
|
||
setSelectedUser={setSelectedUser}
|
||
getUserStatusBadge={getUserStatusBadge}
|
||
dialogOpen={dialogOpen}
|
||
setDialogOpen={setDialogOpen}
|
||
editingUser={editingUser}
|
||
form={form}
|
||
setForm={setForm}
|
||
saving={saving}
|
||
handleSubmit={handleSubmit}
|
||
openCreate={openCreate}
|
||
openEdit={openEdit}
|
||
handleDelete={handleDelete}
|
||
handleBatchDelete={handleBatchDelete}
|
||
selectedUserIds={selectedUserIds}
|
||
setSelectedUserIds={setSelectedUserIds}
|
||
onResetPassword={(u) => {
|
||
setSelectedUser(null);
|
||
setResetPasswordUser(u);
|
||
}}
|
||
importDialogOpen={importDialogOpen}
|
||
setImportDialogOpen={setImportDialogOpen}
|
||
importPreview={importPreview}
|
||
setImportPreview={setImportPreview}
|
||
importing={ldap.importing}
|
||
importResult={importResult}
|
||
setImportResult={setImportResult}
|
||
handleCsvUpload={handleCsvUpload}
|
||
handleBatchImport={handleBatchImport}
|
||
ldapUsers={ldap.ldapUsers}
|
||
ldapSelected={ldap.ldapSelected}
|
||
setLdapSelected={ldap.setLdapSelected}
|
||
ldapLoading={ldap.ldapLoading}
|
||
ldapImportOUs={ldap.ldapImportOUs}
|
||
ldapImportOUDN={ldap.ldapImportOUDN}
|
||
setLdapImportOUDN={ldap.setLdapImportOUDN}
|
||
loadLdapUsers={ldap.loadLdapUsers}
|
||
handleLdapImport={() =>
|
||
ldap.handleLdapImport({ setResult: setImportResult })
|
||
}
|
||
setLdapUsers={ldap.setLdapUsers}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "roles" && (
|
||
<SystemRolesTab
|
||
selectedRole={selectedRole}
|
||
setSelectedRole={setSelectedRole}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === "settings" && (
|
||
<SystemSettingsTab
|
||
{...ldap.settingsTabProps}
|
||
auditRetentionDays={audit.retentionDays}
|
||
setAuditRetentionDays={audit.setRetentionDays}
|
||
auditRecordAll={audit.recordAll}
|
||
setAuditRecordAll={audit.setRecordAll}
|
||
sessionWatermarkEnabled={audit.sessionWatermarkEnabled}
|
||
setSessionWatermarkEnabled={audit.setSessionWatermarkEnabled}
|
||
sessionRecordEnabled={audit.sessionRecordEnabled}
|
||
setSessionRecordEnabled={audit.setSessionRecordEnabled}
|
||
clipboardEnabled={audit.clipboardEnabled}
|
||
setClipboardEnabled={audit.setClipboardEnabled}
|
||
fileUploadEnabled={audit.fileUploadEnabled}
|
||
setFileUploadEnabled={audit.setFileUploadEnabled}
|
||
fileDownloadEnabled={audit.fileDownloadEnabled}
|
||
setFileDownloadEnabled={audit.setFileDownloadEnabled}
|
||
commandPolicyEnabled={audit.commandPolicyEnabled}
|
||
setCommandPolicyEnabled={audit.setCommandPolicyEnabled}
|
||
approvalRequired={audit.approvalRequired}
|
||
setApprovalRequired={audit.setApprovalRequired}
|
||
auditLoading={audit.loading}
|
||
auditApplying={audit.applying}
|
||
handleAuditApply={audit.handleApply}
|
||
auditLastCleanupText={audit.lastCleanupText}
|
||
auditRetentionOptions={audit.retentionOptions}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<ResetPasswordDialog
|
||
user={resetPasswordUser}
|
||
onClose={() => setResetPasswordUser(null)}
|
||
/>
|
||
</PageShell>
|
||
);
|
||
}
|