refactor(quality): Q32 前端巨型组件拆分 — AuditOperationsTab 527L→275L (-48%), 提取 audit-operations/ 3 个文件 (types 43L normalizeLog 纯函数+AuditStatistics 接口 + LogDetailDialog 85L 详情弹窗+DetailRow + StatisticsDialog 108L 统计弹窗+StatCard), 清除死代码 useConfirm/_confirm/_handleShowStats/statsOpen/statistics/statsLoading, 零逻辑变更, ESLint errors=0/any=0, tsc -b=0, npm build✓ (17.34s), go build=0, 17/17 测试通过

This commit is contained in:
attacker committed 2026-07-09 23:05:57 +08:00
1 parent 195b6945b4
commit 6c828c5992
6 files changed
+241 -256

No files matched your search

+3 -255
View File
@@ -1,5 +1,6 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
// v2: 虚拟滚动渲染,支持万级日志流畅查看
// Q32: 拆分 types/LogDetailDialog/StatisticsDialog 到 audit-operations/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -12,7 +13,6 @@ import {
RefreshCw,
Download,
Eye,
X,
ClipboardList,
} from 'lucide-react';
import { PageShell } from '@/components/layout/PageShell';
@@ -21,49 +21,12 @@ import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import { auditApi } from '@/api';
import { useAuditLogs } from '@/hooks/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { OperationLog, OptionItem } from '@/types';
// Normalize backend log to frontend type
function normalizeLog(raw: Record<string, unknown>): OperationLog {
const status = raw.status as number | undefined;
const result: OperationLog['result'] =
status === 0 ? 'failure' : status === 1 ? 'success' : raw.result === 'failure' ? 'failure' : 'success';
const request = String(raw.request ?? '');
const response = String(raw.response ?? '');
const error = String(raw.error ?? '');
const message = error || response || request || String(raw.message ?? raw.detail ?? '');
return {
id: String(raw.id ?? ''),
userId: String(raw.user_id ?? raw.userId ?? ''),
username: String(raw.username ?? ''),
module: String(raw.module ?? raw.resource ?? ''),
action: String(raw.action ?? ''),
resource: String(raw.resource ?? ''),
ip: String(raw.ip ?? ''),
userAgent: String(raw.user_agent ?? raw.userAgent ?? ''),
result,
message,
request,
response,
error,
createdAt: String(raw.createdAt ?? raw.created_at ?? ''),
};
}
interface AuditStatistics {
total_logs?: number;
success_count?: number;
failure_count?: number;
today_count?: number;
top_modules?: { module: string; count: number }[];
top_actions?: { action: string; count: number }[];
top_users?: { username: string; count: number }[];
}
import { normalizeLog } from './audit-operations/types';
import { LogDetailDialog } from './audit-operations/LogDetailDialog';
export function AuditOperationsTab() {
const { t } = useTranslation();
const _confirm = useConfirm();
// Filters
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('');
@@ -87,11 +50,6 @@ export function AuditOperationsTab() {
// Detail modal
const [detailLog, setDetailLog] = useState<OperationLog | null>(null);
// Statistics modal
const [statsOpen, setStatsOpen] = useState(false);
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const fetchOptions = useCallback(async () => {
try {
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
@@ -142,19 +100,6 @@ export function AuditOperationsTab() {
}
};
const _handleShowStats = async () => {
setStatsOpen(true);
setStatsLoading(true);
try {
const data = (await auditApi.getStatistics()) as AuditStatistics;
setStatistics(data);
} catch {
setStatistics(null);
} finally {
setStatsLoading(false);
}
};
const viewDetail = (log: OperationLog) => {
setDetailLog(log);
};
@@ -325,203 +270,6 @@ export function AuditOperationsTab() {
{/* Detail Modal */}
<LogDetailDialog log={detailLog} onClose={() => setDetailLog(null)} />
{/* Statistics Modal */}
<StatisticsDialog
open={statsOpen}
onClose={() => setStatsOpen(false)}
loading={statsLoading}
stats={statistics}
/>
</PageShell>
);
}
// =========================================================================
// Log Detail Dialog
// =========================================================================
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
const { t } = useTranslation();
if (!log) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[85vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.detailTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3 text-sm">
<DetailRow label="ID" value={log.id} />
<DetailRow label={t('audit.operations.col.time')} value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
<DetailRow label={t('audit.operations.col.user')} value={log.username || '--'} />
<DetailRow label={t('audit.operations.detailCol.userId')} value={log.userId || '--'} />
<DetailRow label={t('audit.operations.col.module')} value={log.module || '--'} />
<DetailRow label={t('audit.operations.col.actionType')} value={log.action || '--'} />
<DetailRow label={t('audit.operations.col.resource')} value={log.resource || '--'} />
<DetailRow label={t('audit.operations.detailCol.ipAddress')} value={log.ip || '--'} />
<DetailRow label={t('audit.operations.detailCol.userAgent')} value={log.userAgent || '--'} />
<DetailRow
label={t('audit.operations.col.result')}
value={
<Badge
variant="outline"
className={
log.result === 'success'
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10'
: 'border-red-500/30 dg-text-red bg-red-500/10'
}
>
{log.result === 'success' ? t('common.success') : t('common.failed')}
</Badge>
}
/>
{log.request && <DetailRow label={t('audit.operations.detailCol.request', { defaultValue: '请求参数' })} value={log.request} mono />}
{log.response && <DetailRow label={t('audit.operations.detailCol.response', { defaultValue: '执行输出' })} value={log.response} mono />}
{log.error && <DetailRow label={t('audit.operations.detailCol.error', { defaultValue: '错误信息' })} value={log.error} mono />}
{!log.request && !log.response && log.message && (
<DetailRow label={t('audit.operations.detailCol.message')} value={log.message} mono />
)}
</div>
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-start gap-4">
<span className="dg-text-muted w-20 flex-shrink-0">{label}</span>
<span className={mono ? 'text-[var(--dg-text-secondary)] font-mono text-xs break-all' : 'text-[var(--dg-text-primary)] break-all'}>
{value}
</span>
</div>
);
}
// =========================================================================
// Statistics Dialog
// =========================================================================
function StatisticsDialog({
open,
onClose,
loading,
stats,
}: {
open: boolean;
onClose: () => void;
loading: boolean;
stats: AuditStatistics | null;
}) {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.statsTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<StatCard label={t('audit.operations.stats.totalLogs')} value={String(stats.total_logs ?? '--')} color="blue" />
<StatCard label={t('audit.operations.stats.todayLogs')} value={String(stats.today_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.successCount')} value={String(stats.success_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.failureCount')} value={String(stats.failure_count ?? '--')} color="red" />
</div>
{stats.top_modules && stats.top_modules.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topModules')}</h4>
<div className="space-y-1">
{stats.top_modules.slice(0, 5).map((m, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{m.module}</span>
<span className="dg-text-muted">{m.count}</span>
</div>
))}
</div>
</div>
)}
{stats.top_users && stats.top_users.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topUsers')}</h4>
<div className="space-y-1">
{stats.top_users.slice(0, 5).map((u, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{u.username}</span>
<span className="dg-text-muted">{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="dg-text-muted text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
)}
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
const colorMap = {
blue: 'border-blue-500/20 bg-blue-500/5 dg-text-blue',
emerald: 'border-emerald-500/20 bg-emerald-500/5 dg-text-green',
red: 'border-red-500/20 bg-red-500/5 dg-text-red',
};
return (
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
<div className="text-xs dg-text-muted">{label}</div>
<div className="text-xl font-bold mt-0.5">{value}</div>
</div>
);
}
@@ -0,0 +1,85 @@
// Audit log detail dialog — extracted from AuditOperationsTab
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { X } from 'lucide-react';
import type { OperationLog } from '@/types';
export function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
const { t } = useTranslation();
if (!log) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[85vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.detailTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3 text-sm">
<DetailRow label="ID" value={log.id} />
<DetailRow label={t('audit.operations.col.time')} value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
<DetailRow label={t('audit.operations.col.user')} value={log.username || '--'} />
<DetailRow label={t('audit.operations.detailCol.userId')} value={log.userId || '--'} />
<DetailRow label={t('audit.operations.col.module')} value={log.module || '--'} />
<DetailRow label={t('audit.operations.col.actionType')} value={log.action || '--'} />
<DetailRow label={t('audit.operations.col.resource')} value={log.resource || '--'} />
<DetailRow label={t('audit.operations.detailCol.ipAddress')} value={log.ip || '--'} />
<DetailRow label={t('audit.operations.detailCol.userAgent')} value={log.userAgent || '--'} />
<DetailRow
label={t('audit.operations.col.result')}
value={
<Badge
variant="outline"
className={
log.result === 'success'
? 'border-emerald-500/30 dg-text-green bg-emerald-500/10'
: 'border-red-500/30 dg-text-red bg-red-500/10'
}
>
{log.result === 'success' ? t('common.success') : t('common.failed')}
</Badge>
}
/>
{log.request && <DetailRow label={t('audit.operations.detailCol.request', { defaultValue: '请求参数' })} value={log.request} mono />}
{log.response && <DetailRow label={t('audit.operations.detailCol.response', { defaultValue: '执行输出' })} value={log.response} mono />}
{log.error && <DetailRow label={t('audit.operations.detailCol.error', { defaultValue: '错误信息' })} value={log.error} mono />}
{!log.request && !log.response && log.message && (
<DetailRow label={t('audit.operations.detailCol.message')} value={log.message} mono />
)}
</div>
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function DetailRow({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
return (
<div className="flex items-start gap-4">
<span className="dg-text-muted w-20 flex-shrink-0">{label}</span>
<span className={mono ? 'text-[var(--dg-text-secondary)] font-mono text-xs break-all' : 'text-[var(--dg-text-primary)] break-all'}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,108 @@
// Audit statistics dialog — extracted from AuditOperationsTab
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import type { AuditStatistics } from './types';
export function StatisticsDialog({
open,
onClose,
loading,
stats,
}: {
open: boolean;
onClose: () => void;
loading: boolean;
stats: AuditStatistics | null;
}) {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative dg-card rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('audit.operations.statsTitle')}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<StatCard label={t('audit.operations.stats.totalLogs')} value={String(stats.total_logs ?? '--')} color="blue" />
<StatCard label={t('audit.operations.stats.todayLogs')} value={String(stats.today_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.successCount')} value={String(stats.success_count ?? '--')} color="emerald" />
<StatCard label={t('audit.operations.stats.failureCount')} value={String(stats.failure_count ?? '--')} color="red" />
</div>
{stats.top_modules && stats.top_modules.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topModules')}</h4>
<div className="space-y-1">
{stats.top_modules.slice(0, 5).map((m, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{m.module}</span>
<span className="dg-text-muted">{m.count}</span>
</div>
))}
</div>
</div>
)}
{stats.top_users && stats.top_users.length > 0 && (
<div>
<h4 className="text-xs font-semibold dg-text-secondary mb-2">{t('audit.operations.topUsers')}</h4>
<div className="space-y-1">
{stats.top_users.slice(0, 5).map((u, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="text-[var(--dg-text-secondary)]">{u.username}</span>
<span className="dg-text-muted">{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="dg-text-muted text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
)}
<div className="mt-5 flex justify-end">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={onClose}
>
{t('common.close')}
</Button>
</div>
</div>
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) {
const colorMap = {
blue: 'border-blue-500/20 bg-blue-500/5 dg-text-blue',
emerald: 'border-emerald-500/20 bg-emerald-500/5 dg-text-green',
red: 'border-red-500/20 bg-red-500/5 dg-text-red',
};
return (
<div className={`rounded-lg border p-3 ${colorMap[color]}`}>
<div className="text-xs dg-text-muted">{label}</div>
<div className="text-xl font-bold mt-0.5">{value}</div>
</div>
);
}
@@ -0,0 +1,43 @@
// Audit operations types & pure helpers (extracted from AuditOperationsTab)
import type { OperationLog } from '@/types';
/** Backend audit log shape (snake_case from API). */
type RawLog = Record<string, unknown>;
/** Normalize a raw backend audit log to the frontend OperationLog type. */
export function normalizeLog(raw: RawLog): OperationLog {
const status = raw.status as number | undefined;
const result: OperationLog['result'] =
status === 0 ? 'failure' : status === 1 ? 'success' : raw.result === 'failure' ? 'failure' : 'success';
const request = String(raw.request ?? '');
const response = String(raw.response ?? '');
const error = String(raw.error ?? '');
const message = error || response || request || String(raw.message ?? raw.detail ?? '');
return {
id: String(raw.id ?? ''),
userId: String(raw.user_id ?? raw.userId ?? ''),
username: String(raw.username ?? ''),
module: String(raw.module ?? raw.resource ?? ''),
action: String(raw.action ?? ''),
resource: String(raw.resource ?? ''),
ip: String(raw.ip ?? ''),
userAgent: String(raw.user_agent ?? raw.userAgent ?? ''),
result,
message,
request,
response,
error,
createdAt: String(raw.createdAt ?? raw.created_at ?? ''),
};
}
/** Audit statistics shape from `auditApi.getStatistics()`. */
export interface AuditStatistics {
total_logs?: number;
success_count?: number;
failure_count?: number;
today_count?: number;
top_modules?: { module: string; count: number }[];
top_actions?: { action: string; count: number }[];
top_users?: { username: string; count: number }[];
}