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:
LoganandClaude committed 2026-07-01 23:44:45 +08:00
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);
}