feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -166,8 +167,8 @@ export function CredentialManager() {
|
||||
)}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchList}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchList} disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />刷新
|
||||
</Button>
|
||||
<Button className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />添加凭据
|
||||
|
||||
@@ -57,7 +57,7 @@ export function LdapImportDialog({
|
||||
}: LdapImportDialogProps) {
|
||||
return (
|
||||
<Dialog open={importDialogOpen === 'ldap'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
|
||||
<DialogContent className="dg-card max-w-2xl">
|
||||
<DialogContent className="dg-card max-w-6xl w-[calc(100vw-2rem)] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Network className="h-5 w-5 dg-text-cyan" />
|
||||
@@ -70,7 +70,7 @@ export function LdapImportDialog({
|
||||
|
||||
{!importResult ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs dg-text-muted whitespace-nowrap">选择 OU</span>
|
||||
@@ -99,10 +99,10 @@ export function LdapImportDialog({
|
||||
</p>
|
||||
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<Table>
|
||||
<div className="flex-1 min-h-0 max-h-[min(28rem,50vh)] overflow-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<Table className="table-fixed w-full min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] sticky top-0 bg-[var(--dg-bg-secondary)]">
|
||||
<TableRow className="border-[var(--dg-border)] sticky top-0 bg-[var(--dg-bg-secondary)] z-10">
|
||||
<TableHead className="w-10 dg-text-secondary text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -117,9 +117,9 @@ export function LdapImportDialog({
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">用户名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">显示名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">邮箱</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[100px]">用户名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[120px]">显示名</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs w-[160px]">邮箱</TableHead>
|
||||
<TableHead className="dg-text-secondary text-xs">DN</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -142,7 +142,7 @@ export function LdapImportDialog({
|
||||
<TableCell className="text-[var(--dg-text-primary)] text-xs">{u.uid}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-secondary)] text-xs">{u.cn}</TableCell>
|
||||
<TableCell className="dg-text-secondary text-xs">{u.mail}</TableCell>
|
||||
<TableCell className="dg-text-muted text-xs font-mono truncate max-w-[200px]">{u.dn}</TableCell>
|
||||
<TableCell className="dg-text-muted text-xs font-mono break-all whitespace-normal leading-relaxed" title={u.dn}>{u.dn}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -50,7 +50,7 @@ interface LdapManagementSectionProps {
|
||||
setSelectedLDAPUsers: (v: string[]) => void;
|
||||
loadLdapOUs: () => void;
|
||||
loadLdapOUUsers: (ouDn?: string) => void;
|
||||
handleLdapImport: () => void;
|
||||
handleLdapImport: (opts?: { importAll?: boolean }) => void | Promise<void>;
|
||||
importing: boolean;
|
||||
importFeedback: string | null;
|
||||
importResultOpen: boolean;
|
||||
@@ -309,7 +309,7 @@ export function LdapManagementSection(props: LdapManagementSectionProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-600/50 dg-text-blue hover:bg-blue-600/10 ml-auto"
|
||||
onClick={handleLdapImport}
|
||||
onClick={() => handleLdapImport({ importAll: selectedLDAPUsers.length === 0 })}
|
||||
disabled={ldapUsers.length === 0 || importing}
|
||||
>
|
||||
{importing ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Download className="h-3.5 w-3.5 mr-1" />}
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { CheckCircle, XCircle, Settings } from 'lucide-react';
|
||||
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';
|
||||
@@ -12,6 +13,7 @@ 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';
|
||||
|
||||
// 后端用户数据转换为前端兼容格式
|
||||
@@ -40,20 +42,32 @@ export function SystemManager() {
|
||||
const confirm = useConfirm();
|
||||
const location = useLocation();
|
||||
// 侧边栏菜单路径映射到 Tab // Logan.Li
|
||||
const pathToTab: Record<string, string> = {
|
||||
// 侧边栏已有独立菜单项,按路由直接渲染对应页面,不再重复 Tab // Logan.Li
|
||||
const pathToTab: Record<string, 'users' | 'roles' | 'settings'> = {
|
||||
'/users': 'users',
|
||||
'/roles': 'roles',
|
||||
'/settings': 'settings',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
|
||||
const activeTab = pathToTab[location.pathname] || 'users';
|
||||
|
||||
// URL 变化时同步 Tab(侧边栏菜单点击触发) // Logan.Li
|
||||
useEffect(() => {
|
||||
const tab = pathToTab[location.pathname];
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
const pageMeta: Record<string, { title: string; subtitle: string; icon: React.ReactNode }> = {
|
||||
users: {
|
||||
title: '用户管理',
|
||||
subtitle: '本地与 LDAP 用户账号、状态与角色分配',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
roles: {
|
||||
title: '角色权限',
|
||||
subtitle: '角色绑定权限码,同时约束前端菜单与后端 API',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
subtitle: '通用与审计、安全策略、LDAP 目录服务',
|
||||
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);
|
||||
@@ -71,6 +85,7 @@ export function SystemManager() {
|
||||
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);
|
||||
@@ -88,6 +103,7 @@ export function SystemManager() {
|
||||
|
||||
// LDAP 状态全部由 hook 管理(在 loadUsers 之后调用避免 TDZ) // Logan.Li
|
||||
const ldap = useLdapManagement(loadUsers);
|
||||
const audit = useAuditSettings();
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -175,9 +191,30 @@ export function SystemManager() {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await userApi.deleteUser(user.id);
|
||||
setSelectedUserIds((prev) => prev.filter((id) => id !== user.id));
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
toast.success('用户已删除');
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: '批量删除用户',
|
||||
description: `确定删除选中的 ${selectedUserIds.length} 个用户吗?此操作不可恢复。`,
|
||||
confirmText: '批量删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await userApi.batchDelete(selectedUserIds) as { message?: string };
|
||||
toast.success(res?.message || `已删除 ${selectedUserIds.length} 个用户`);
|
||||
setSelectedUserIds([]);
|
||||
await loadUsers();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,21 +268,13 @@ export function SystemManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'users', label: '用户管理', badge: users.length || undefined },
|
||||
{ id: 'roles', label: '角色权限' },
|
||||
{ id: 'settings', label: '系统设置' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="系统管理"
|
||||
subtitle="用户、权限与系统配置"
|
||||
icon={<Settings className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
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' && (
|
||||
@@ -268,6 +297,9 @@ export function SystemManager() {
|
||||
openCreate={openCreate}
|
||||
openEdit={openEdit}
|
||||
handleDelete={handleDelete}
|
||||
handleBatchDelete={handleBatchDelete}
|
||||
selectedUserIds={selectedUserIds}
|
||||
setSelectedUserIds={setSelectedUserIds}
|
||||
onResetPassword={(u) => { setSelectedUser(null); setResetPasswordUser(u); }}
|
||||
importDialogOpen={importDialogOpen}
|
||||
setImportDialogOpen={setImportDialogOpen}
|
||||
@@ -286,7 +318,7 @@ export function SystemManager() {
|
||||
ldapImportOUDN={ldap.ldapImportOUDN}
|
||||
setLdapImportOUDN={ldap.setLdapImportOUDN}
|
||||
loadLdapUsers={ldap.loadLdapUsers}
|
||||
handleLdapImport={ldap.handleLdapImport}
|
||||
handleLdapImport={() => ldap.handleLdapImport({ setResult: setImportResult })}
|
||||
setLdapUsers={ldap.setLdapUsers}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +332,18 @@ export function SystemManager() {
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SystemSettingsTab {...ldap.settingsTabProps} />
|
||||
<SystemSettingsTab
|
||||
{...ldap.settingsTabProps}
|
||||
auditRetentionDays={audit.retentionDays}
|
||||
setAuditRetentionDays={audit.setRetentionDays}
|
||||
auditRecordAll={audit.recordAll}
|
||||
setAuditRecordAll={audit.setRecordAll}
|
||||
auditLoading={audit.loading}
|
||||
auditApplying={audit.applying}
|
||||
handleAuditApply={audit.handleApply}
|
||||
auditLastCleanupText={audit.lastCleanupText}
|
||||
auditRetentionOptions={audit.retentionOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -185,6 +185,14 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3 flex items-start gap-3">
|
||||
<ShieldCheck className="h-5 w-5 dg-text-blue shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] space-y-1">
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">权限如何生效</p>
|
||||
<p>用户通过「用户管理」绑定角色;角色在此配置权限码。权限码同时作用于<strong className="text-[var(--dg-text-primary)]">后端 API 鉴权</strong>与<strong className="text-[var(--dg-text-primary)]">前端菜单/按钮显隐</strong>,修改后用户需重新登录生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{roles.length} 个角色</span>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,9 +12,18 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Lock, History, Key, Settings, Network, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { PageTabs } from '@/components/layout/PageTabs';
|
||||
import { LdapManagementSection } from './LdapManagementSection';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// 系统设置子 Tab:通用 / 安全 / LDAP // Logan.Li
|
||||
const SETTINGS_TABS = [
|
||||
{ id: 'general', label: '通用' },
|
||||
{ id: 'security', label: '安全' },
|
||||
{ id: 'ldap', label: 'LDAP' },
|
||||
] as const;
|
||||
type SettingsSubTab = (typeof SETTINGS_TABS)[number]['id'];
|
||||
|
||||
export interface LdapFormData {
|
||||
ldap_host: string;
|
||||
ldap_port: number;
|
||||
@@ -24,6 +35,16 @@ export interface LdapFormData {
|
||||
}
|
||||
|
||||
interface SystemSettingsTabProps {
|
||||
// 审计策略 // Logan.Li
|
||||
auditRetentionDays: string;
|
||||
setAuditRetentionDays: (v: string) => void;
|
||||
auditRecordAll: boolean;
|
||||
setAuditRecordAll: (v: boolean) => void;
|
||||
auditLoading: boolean;
|
||||
auditApplying: boolean;
|
||||
handleAuditApply: () => void;
|
||||
auditLastCleanupText: string | null;
|
||||
auditRetentionOptions: number[];
|
||||
// LDAP config
|
||||
ldapConfig: unknown;
|
||||
ldapForm: LdapFormData;
|
||||
@@ -44,7 +65,7 @@ interface SystemSettingsTabProps {
|
||||
setSelectedLDAPUsers: (v: string[]) => void;
|
||||
loadLdapOUs: () => void;
|
||||
loadLdapOUUsers: (ouDn?: string) => void;
|
||||
handleLdapImport: () => void;
|
||||
handleLdapImport: (opts?: { importAll?: boolean }) => void | Promise<void>;
|
||||
importing: boolean;
|
||||
importFeedback: string | null;
|
||||
importResultOpen: boolean;
|
||||
@@ -79,6 +100,15 @@ interface SystemSettingsTabProps {
|
||||
|
||||
export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
const {
|
||||
auditRetentionDays,
|
||||
setAuditRetentionDays,
|
||||
auditRecordAll,
|
||||
setAuditRecordAll,
|
||||
auditLoading,
|
||||
auditApplying,
|
||||
handleAuditApply,
|
||||
auditLastCleanupText,
|
||||
auditRetentionOptions,
|
||||
ldapConfig,
|
||||
ldapForm,
|
||||
setLdapForm,
|
||||
@@ -89,43 +119,33 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
handleLdapTest,
|
||||
} = props;
|
||||
|
||||
// 作为 SystemManager 子 Tab 渲染,外层已有 PageShell,此处仅输出配置内容 // Logan.Li
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 dg-text-blue" />
|
||||
安全设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">密码策略与登录安全</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">强制密码复杂度</p>
|
||||
<p className="text-xs dg-text-muted">要求密码包含大小写字母、数字和特殊字符</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">登录失败锁定</p>
|
||||
<p className="text-xs dg-text-muted">连续5次登录失败锁定账户30分钟</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">双因素认证</p>
|
||||
<p className="text-xs dg-text-muted">启用后需要手机验证码登录</p>
|
||||
</div>
|
||||
<Switch className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabFromUrl = searchParams.get('tab') as SettingsSubTab | null;
|
||||
const [subTab, setSubTab] = useState<SettingsSubTab>(
|
||||
SETTINGS_TABS.some(t => t.id === tabFromUrl) ? tabFromUrl! : 'general',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabFromUrl && SETTINGS_TABS.some(t => t.id === tabFromUrl) && tabFromUrl !== subTab) {
|
||||
setSubTab(tabFromUrl);
|
||||
}
|
||||
}, [tabFromUrl, subTab]);
|
||||
|
||||
const changeSubTab = (id: string) => {
|
||||
const next = id as SettingsSubTab;
|
||||
setSubTab(next);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next === 'general') params.delete('tab');
|
||||
else params.set('tab', next);
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<PageTabs items={[...SETTINGS_TABS]} value={subTab} onChange={changeSubTab} />
|
||||
|
||||
{subTab === 'general' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
@@ -140,60 +160,56 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">记录所有操作</p>
|
||||
<p className="text-xs dg-text-muted">记录用户的所有操作行为</p>
|
||||
</div>
|
||||
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
<Switch
|
||||
checked={auditRecordAll}
|
||||
onCheckedChange={setAuditRecordAll}
|
||||
disabled={auditLoading || auditApplying}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">日志保留时间</p>
|
||||
<p className="text-xs dg-text-muted">操作日志保留180天</p>
|
||||
<p className="text-xs dg-text-muted">
|
||||
应用后立即清理超期记录;之后每天自动检查
|
||||
</p>
|
||||
{auditLastCleanupText && (
|
||||
<p className="text-xs dg-text-muted mt-1">{auditLastCleanupText}</p>
|
||||
)}
|
||||
</div>
|
||||
<Select defaultValue="180">
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<Select
|
||||
value={auditRetentionDays}
|
||||
onValueChange={setAuditRetentionDays}
|
||||
disabled={auditLoading || auditApplying}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 dg-input shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="30">30天</SelectItem>
|
||||
<SelectItem value="90">90天</SelectItem>
|
||||
<SelectItem value="180">180天</SelectItem>
|
||||
<SelectItem value="365">1年</SelectItem>
|
||||
{auditRetentionOptions.map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{d}天
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Key className="h-5 w-5 dg-text-yellow" />
|
||||
会话设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">会话超时与Token配置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">会话超时时间</p>
|
||||
<p className="text-xs dg-text-muted">用户无操作30分钟后自动登出</p>
|
||||
</div>
|
||||
<Select defaultValue="30">
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="15">15分钟</SelectItem>
|
||||
<SelectItem value="30">30分钟</SelectItem>
|
||||
<SelectItem value="60">1小时</SelectItem>
|
||||
<SelectItem value="120">2小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">单点登录</p>
|
||||
<p className="text-xs dg-text-muted">同一账号只能在一个设备登录</p>
|
||||
</div>
|
||||
<Switch className="data-[state=checked]:bg-blue-600" />
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="dg-btn-primary"
|
||||
onClick={handleAuditApply}
|
||||
disabled={auditLoading || auditApplying}
|
||||
>
|
||||
{auditApplying ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
应用中...
|
||||
</>
|
||||
) : (
|
||||
'应用'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -218,9 +234,84 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LDAP 配置 */}
|
||||
<Card className="dg-card mt-4">
|
||||
{subTab === 'security' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 dg-text-blue" />
|
||||
安全设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">密码策略与登录安全(规划中)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">强制密码复杂度</p>
|
||||
<p className="text-xs dg-text-muted">要求密码包含大小写字母、数字和特殊字符</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">登录失败锁定</p>
|
||||
<p className="text-xs dg-text-muted">连续5次登录失败锁定账户30分钟</p>
|
||||
</div>
|
||||
<Switch disabled defaultChecked className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">双因素认证</p>
|
||||
<p className="text-xs dg-text-muted">启用后需要手机验证码登录</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Key className="h-5 w-5 dg-text-yellow" />
|
||||
会话设置
|
||||
</CardTitle>
|
||||
<CardDescription className="dg-text-muted">会话超时与 Token 配置(规划中)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">会话超时时间</p>
|
||||
<p className="text-xs dg-text-muted">用户无操作30分钟后自动登出</p>
|
||||
</div>
|
||||
<Select defaultValue="30" disabled>
|
||||
<SelectTrigger className="w-32 h-8 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="15">15分钟</SelectItem>
|
||||
<SelectItem value="30">30分钟</SelectItem>
|
||||
<SelectItem value="60">1小时</SelectItem>
|
||||
<SelectItem value="120">2小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">单点登录</p>
|
||||
<p className="text-xs dg-text-muted">同一账号只能在一个设备登录</p>
|
||||
</div>
|
||||
<Switch disabled className="data-[state=checked]:bg-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'ldap' && (
|
||||
<>
|
||||
<Card className="dg-card pt-2">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -233,7 +324,7 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs dg-text-muted">LDAP 服务器地址</label>
|
||||
<Input
|
||||
@@ -331,11 +422,8 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LDAP 账号管理 - 可折叠 */}
|
||||
{ldapConfig && (
|
||||
<div className="mt-4">
|
||||
<LdapManagementSection {...props} />
|
||||
</div>
|
||||
{ldapConfig && <LdapManagementSection {...props} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { UserFormDialog, type UserFormData } from './UserFormDialog';
|
||||
import { UserDetailDialog } from './UserDetailDialog';
|
||||
import { CsvImportDialog } from './CsvImportDialog';
|
||||
@@ -49,6 +50,9 @@ interface SystemUserTabProps {
|
||||
openCreate: () => void;
|
||||
openEdit: ( user: any) => void;
|
||||
handleDelete: ( user: any) => void;
|
||||
handleBatchDelete: () => void;
|
||||
selectedUserIds: number[];
|
||||
setSelectedUserIds: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
onResetPassword?: (user: any) => void;
|
||||
// CSV import
|
||||
importDialogOpen: 'csv' | 'ldap' | null;
|
||||
@@ -91,6 +95,9 @@ export function SystemUserTab({
|
||||
openCreate,
|
||||
openEdit,
|
||||
handleDelete,
|
||||
handleBatchDelete,
|
||||
selectedUserIds,
|
||||
setSelectedUserIds,
|
||||
onResetPassword,
|
||||
importDialogOpen,
|
||||
setImportDialogOpen,
|
||||
@@ -113,6 +120,19 @@ export function SystemUserTab({
|
||||
setLdapUsers,
|
||||
}: SystemUserTabProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
const canManage = hasPermission('system:user');
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const allSelected = filteredUsers.length > 0 && selectedUserIds.length === filteredUsers.length;
|
||||
const toggleSelectAll = () => {
|
||||
setSelectedUserIds(allSelected ? [] : filteredUsers.map((u) => u.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filters */}
|
||||
@@ -148,6 +168,23 @@ export function SystemUserTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && selectedUserIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<span className="text-sm dg-text-secondary">
|
||||
已选择 {selectedUserIds.length} 项 / 共 {filteredUsers.length} 项
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => setSelectedUserIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger dg-btn-sm" onClick={handleBatchDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
@@ -155,6 +192,15 @@ export function SystemUserTab({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
{canManage && (
|
||||
<TableHead className="w-10 text-[var(--dg-text-secondary)]">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">用户信息</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">联系方式</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
|
||||
@@ -167,7 +213,7 @@ export function SystemUserTab({
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={canManage ? 8 : 7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-6 border-2 border-[var(--dg-border)] border-t-[var(--dg-blue)] rounded-full animate-spin" />
|
||||
</div>
|
||||
@@ -175,13 +221,22 @@ export function SystemUserTab({
|
||||
</TableRow>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
<TableCell colSpan={canManage ? 8 : 7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50">
|
||||
{canManage && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onCheckedChange={() => toggleSelect(user.id)}
|
||||
className="border-[var(--dg-border)]"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-sm font-medium">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -39,7 +40,7 @@ function formatSize(bytes: number): string {
|
||||
*/
|
||||
export function TerminalSessionsTab() {
|
||||
const confirm = useConfirm();
|
||||
const { data, isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const { data, isLoading: loading, isFetching, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const sessions = data?.list ?? [];
|
||||
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -175,8 +176,9 @@ export function TerminalSessionsTab() {
|
||||
variant="outline"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
{selected.size > 0 && (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 审计策略配置 — 保留天数与应用后立即清理 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { auditSettingsApi, type AuditSettings } from '@/api';
|
||||
|
||||
const RETENTION_OPTIONS = [7, 15, 30, 90, 180, 365] as const;
|
||||
|
||||
export function useAuditSettings() {
|
||||
const [settings, setSettings] = useState<AuditSettings | null>(null);
|
||||
const [retentionDays, setRetentionDays] = useState('180');
|
||||
const [recordAll, setRecordAll] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await auditSettingsApi.get();
|
||||
setSettings(data);
|
||||
setRetentionDays(String(data.retention_days || 180));
|
||||
setRecordAll(data.record_all !== false);
|
||||
} catch {
|
||||
toast.error('加载审计设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleApply = async () => {
|
||||
const days = Number(retentionDays);
|
||||
if (!RETENTION_OPTIONS.includes(days as (typeof RETENTION_OPTIONS)[number])) {
|
||||
toast.error('请选择有效的保留天数');
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
try {
|
||||
const res = await auditSettingsApi.apply({ retention_days: days, record_all: recordAll });
|
||||
setSettings({
|
||||
retention_days: res.retention_days ?? days,
|
||||
record_all: res.record_all ?? recordAll,
|
||||
last_cleanup_at: res.last_cleanup_at,
|
||||
last_cleanup_summary: res.last_cleanup_summary,
|
||||
});
|
||||
const total = res.cleanup?.total ?? 0;
|
||||
toast.success(
|
||||
total > 0
|
||||
? `已应用 ${days} 天保留策略,立即清理 ${total} 条超期记录`
|
||||
: `已应用 ${days} 天保留策略,暂无超期记录需清理`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '应用失败');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastCleanupText = (() => {
|
||||
if (!settings?.last_cleanup_at) return null;
|
||||
const t = new Date(settings.last_cleanup_at).toLocaleString('zh-CN', { hour12: false });
|
||||
let detail = '';
|
||||
if (settings.last_cleanup_summary) {
|
||||
try {
|
||||
const s = JSON.parse(settings.last_cleanup_summary) as { total?: number };
|
||||
if (typeof s.total === 'number') {
|
||||
detail = s.total > 0 ? `,清理 ${s.total} 条` : ',无超期记录';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return `上次清理:${t}${detail}`;
|
||||
})();
|
||||
|
||||
return {
|
||||
retentionDays,
|
||||
setRetentionDays,
|
||||
recordAll,
|
||||
setRecordAll,
|
||||
loading,
|
||||
applying,
|
||||
handleApply,
|
||||
lastCleanupText,
|
||||
retentionOptions: settings?.allowed_retention_days ?? [...RETENTION_OPTIONS],
|
||||
};
|
||||
}
|
||||
@@ -220,30 +220,57 @@ export function useLdapManagement(onUsersChanged: () => Promise<void> | void) {
|
||||
}
|
||||
};
|
||||
|
||||
/** 执行 LDAP 批量导入 // Logan.Li */
|
||||
const handleLdapImport = async () => {
|
||||
let selected: Record<string, unknown>[] = [];
|
||||
if (selectedLDAPUsers.length > 0) {
|
||||
/** 执行 LDAP 批量导入;用户管理弹窗用 ldapSelected,设置页可传 importAll 导入全部 // Logan.Li */
|
||||
const handleLdapImport = async (opts?: {
|
||||
importAll?: boolean;
|
||||
setResult?: (r: JsonData) => void;
|
||||
}) => {
|
||||
let selected: JsonData[] = [];
|
||||
if (ldapSelected.size > 0) {
|
||||
selected = ldapUsers.filter((u) => ldapSelected.has(u.dn));
|
||||
} else if (selectedLDAPUsers.length > 0) {
|
||||
selected = ldapUsers.filter((u) => selectedLDAPUsers.includes(u.dn));
|
||||
} else if (opts?.importAll) {
|
||||
selected = ldapUsers;
|
||||
} else {
|
||||
selected = ldapUsers; // 没有选择则导入全部
|
||||
toast.error('请至少选择一个用户');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
toast.error('没有可导入的用户');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const mapped = selected.map((u) => ({
|
||||
uid: u.uid || '',
|
||||
cn: u.cn || '',
|
||||
mail: u.mail || '',
|
||||
username: u.uid || '',
|
||||
nickname: u.cn || '',
|
||||
email: u.mail || '',
|
||||
}));
|
||||
const res = await ldapApi.batchImport(mapped);
|
||||
setImportFeedback(res?.message || `成功导入 ${selected.length} 个用户`);
|
||||
setImportResultOpen(true);
|
||||
const message = (res as JsonData)?.message || `成功导入 ${selected.length} 个用户`;
|
||||
const result = { code: 200, message };
|
||||
if (opts?.setResult) {
|
||||
opts.setResult(result);
|
||||
} else {
|
||||
setImportFeedback(message);
|
||||
setImportResultOpen(true);
|
||||
}
|
||||
setLdapSelected(new Set());
|
||||
setSelectedLDAPUsers([]);
|
||||
await onUsersChanged();
|
||||
} catch (error) {
|
||||
setImportFeedback('导入失败: ' + (error?.message || '未知错误'));
|
||||
setImportResultOpen(true);
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '未知错误';
|
||||
const result = { code: 500, message: '导入失败: ' + errMsg };
|
||||
if (opts?.setResult) {
|
||||
opts.setResult(result);
|
||||
} else {
|
||||
setImportFeedback(result.message);
|
||||
setImportResultOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user