feat(i18n): 安全审计模块全量国际化 — 7 文件硬编码中文改翻译键 + audit 命名空间(89 键)

迁移文件:
- AuditOperationsTab.tsx — 操作审计(搜索/筛选/虚拟滚动表格/详情弹窗/统计弹窗)
- TerminalAuditTab.tsx — 终端审计子标签(在线/历史/命令)
- LoginLogsTab.tsx — 登录日志(DataTable + 状态筛选 + 登录方式)
- ActiveSessionsPanel.tsx — 在线会话(自动刷新 + 阻断确认 + 实时监控)
- HistorySessionsPanel.tsx — 历史会话(回放/下载录制)
- SessionCommandsPanel.tsx — 会话命令(虚拟滚动)
- helpers.tsx — formatDuration/getStatusBadge (i18n.t 非组件模式)

翻译键: audit 命名空间 89 键 (operations/login/terminal 三子域)
  + common.duration 时长单位(秒/分/时)
总键数: 481→610, 命名空间: 8→9 (common/menu/header/login/dashboard/server/alert/approval/audit)
zh-CN/en-US 完全对齐, tsc -b=0, npm build✓, 17/17 测试通过
This commit is contained in:
attacker committed 2026-06-29 20:44:08 +08:00
1 parent fef4997391
commit 186ece6495
10 files changed
+371 -137

No files matched your search

+1 -1
View File
@@ -280,7 +280,7 @@ services/api.ts (1324L)
- ✅ **可扩展架构** — `MenuItem.labelKey` 模式支持增量迁移,后续页面只需添加翻译键 + `useTranslation()` 即可 - ✅ **可扩展架构** — `MenuItem.labelKey` 模式支持增量迁移,后续页面只需添加翻译键 + `useTranslation()` 即可
**后续增量迁移路径**(已建立完整模式,各页面可独立迁移): **后续增量迁移路径**(已建立完整模式,各页面可独立迁移):
- Dashboard / ServerManager / Kubernetes 各页面 / AlertCenter 等页面内容翻译 - ✅ Dashboard / ServerManager / Kubernetes 各页面 / AlertCenter / **安全审计(audit)** 等页面内容翻译
- 添加更多语言(如日语 `ja-JP`)只需在 `SUPPORTED_LANGUAGES` + 新 JSON 文件中注册 - 添加更多语言(如日语 `ja-JP`)只需在 `SUPPORTED_LANGUAGES` + 新 JSON 文件中注册
**验收标准**: **验收标准**:
+110 -1
View File
@@ -71,7 +71,14 @@
"operationSuccess": "Operation succeeded", "operationSuccess": "Operation succeeded",
"operationFailed": "Operation failed", "operationFailed": "Operation failed",
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"saveFailed": "Failed to save" "saveFailed": "Failed to save",
"duration": {
"sec": "{{count}}s",
"min": "{{count}}m",
"minSec": "{{m}}m {{s}}s",
"hour": "{{count}}h",
"hourMin": "{{h}}h {{m}}m"
}
}, },
"menu": { "menu": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -545,5 +552,107 @@
"templateNotLoaded": "Template not loaded", "templateNotLoaded": "Template not loaded",
"pleaseFill": "Please fill in \"{{label}}\"" "pleaseFill": "Please fill in \"{{label}}\""
} }
},
"audit": {
"operations": {
"searchPlaceholder": "Search audit logs...",
"allModules": "All Modules",
"allActions": "All Actions",
"noData": "No audit logs",
"viewDetail": "View Details",
"totalLogs": "{{count}} logs",
"virtualScroll": "virtual scroll",
"detailTitle": "Audit Log Details",
"statsTitle": "Audit Statistics",
"noStats": "No statistics available",
"topModules": "Top Modules",
"topUsers": "Active Users",
"col": {
"time": "Time",
"user": "User",
"module": "Module",
"actionType": "Action",
"resource": "Resource",
"ip": "IP",
"result": "Result",
"detail": "Detail"
},
"detailCol": {
"userId": "User ID",
"ipAddress": "IP Address",
"userAgent": "User-Agent",
"message": "Message"
},
"stats": {
"totalLogs": "Total Logs",
"todayLogs": "Today",
"successCount": "Success",
"failureCount": "Failed"
}
},
"login": {
"searchPlaceholder": "Search user/IP/location...",
"noData": "No login logs",
"all": "All",
"col": {
"loginTime": "Login Time",
"user": "User",
"loginType": "Method",
"ip": "IP",
"location": "Location",
"browser": "Browser/OS",
"status": "Status"
},
"type": {
"password": "Password",
"ldap": "LDAP",
"token": "Token",
"oauth": "OAuth"
}
},
"terminal": {
"searchUserServer": "Search user/server...",
"searchUserServerIp": "Search user/server/IP...",
"searchUserCommand": "Search user/command...",
"sessionIdPlaceholder": "Session ID (optional)",
"noActive": "No active sessions",
"noHistory": "No history sessions",
"noCommands": "No command records",
"totalCommands": "{{count}} commands",
"virtualScroll": "virtual scroll",
"autoRefresh": "Auto Refresh",
"autoRefreshHintOn": "Auto-refresh every {{sec}}s · click to disable",
"autoRefreshHintOff": "Click to enable auto-refresh",
"killTitle": "Terminate Session",
"killConfirm": "Force disconnect user \"{{user}}\"'s current session?",
"killConfirmBtn": "Terminate",
"liveMonitor": "Live Monitor",
"replay": "Replay",
"downloadRecord": "Download Recording",
"tab": {
"active": "Active Sessions",
"history": "History Sessions",
"commands": "Session Commands"
},
"col": {
"id": "ID",
"user": "User",
"targetServer": "Target Server",
"port": "Port",
"startTime": "Start Time",
"endTime": "End Time",
"duration": "Duration",
"status": "Status",
"actions": "Actions",
"command": "Command",
"outputSize": "Output Size",
"sessionRef": "Session"
},
"status": {
"active": "Active",
"failed": "Failed",
"closed": "Closed"
}
}
} }
} }
+110 -1
View File
@@ -71,7 +71,14 @@
"operationSuccess": "操作成功", "operationSuccess": "操作成功",
"operationFailed": "操作失败", "operationFailed": "操作失败",
"loadFailed": "加载失败", "loadFailed": "加载失败",
"saveFailed": "保存失败" "saveFailed": "保存失败",
"duration": {
"sec": "{{count}}秒",
"min": "{{count}}分",
"minSec": "{{m}}分{{s}}秒",
"hour": "{{count}}时",
"hourMin": "{{h}}时{{m}}分"
}
}, },
"menu": { "menu": {
"dashboard": "仪表盘", "dashboard": "仪表盘",
@@ -545,5 +552,107 @@
"templateNotLoaded": "模板未加载", "templateNotLoaded": "模板未加载",
"pleaseFill": "请填写「{{label}}」" "pleaseFill": "请填写「{{label}}」"
} }
},
"audit": {
"operations": {
"searchPlaceholder": "搜索操作日志...",
"allModules": "全部模块",
"allActions": "全部操作",
"noData": "暂无操作日志",
"viewDetail": "查看详情",
"totalLogs": "共 {{count}} 条日志",
"virtualScroll": "虚拟滚动渲染",
"detailTitle": "操作日志详情",
"statsTitle": "审计统计",
"noStats": "暂无统计数据",
"topModules": "热门模块",
"topUsers": "活跃用户",
"col": {
"time": "时间",
"user": "用户",
"module": "模块",
"actionType": "操作类型",
"resource": "资源",
"ip": "IP",
"result": "结果",
"detail": "详情"
},
"detailCol": {
"userId": "用户ID",
"ipAddress": "IP地址",
"userAgent": "User-Agent",
"message": "详情"
},
"stats": {
"totalLogs": "总日志数",
"todayLogs": "今日日志",
"successCount": "成功数",
"failureCount": "失败数"
}
},
"login": {
"searchPlaceholder": "搜索用户/IP/归属地...",
"noData": "暂无登录日志",
"all": "全部",
"col": {
"loginTime": "登录时间",
"user": "用户",
"loginType": "登录方式",
"ip": "IP",
"location": "归属地",
"browser": "浏览器/OS",
"status": "状态"
},
"type": {
"password": "密码登录",
"ldap": "LDAP",
"token": "Token",
"oauth": "OAuth"
}
},
"terminal": {
"searchUserServer": "搜索用户/服务器...",
"searchUserServerIp": "搜索用户/服务器/IP...",
"searchUserCommand": "搜索用户/命令...",
"sessionIdPlaceholder": "会话 ID(可选)",
"noActive": "暂无在线会话",
"noHistory": "暂无历史会话",
"noCommands": "暂无会话命令记录",
"totalCommands": "共 {{count}} 条命令",
"virtualScroll": "虚拟滚动渲染",
"autoRefresh": "自动刷新",
"autoRefreshHintOn": "每 {{sec}}s 自动刷新 · 点击关闭",
"autoRefreshHintOff": "点击开启自动刷新",
"killTitle": "阻断会话",
"killConfirm": "确认强制断开用户「{{user}}」的当前会话吗?",
"killConfirmBtn": "阻断",
"liveMonitor": "实时查看",
"replay": "回放",
"downloadRecord": "下载录制",
"tab": {
"active": "在线会话",
"history": "历史会话",
"commands": "会话命令"
},
"col": {
"id": "ID",
"user": "用户",
"targetServer": "目标服务器",
"port": "端口",
"startTime": "开始时间",
"endTime": "结束时间",
"duration": "时长",
"status": "状态",
"actions": "操作",
"command": "命令",
"outputSize": "输出大小",
"sessionRef": "关联会话"
},
"status": {
"active": "活动中",
"failed": "失败",
"closed": "已关闭"
}
}
} }
} }
+44 -40
View File
@@ -1,6 +1,7 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除 // 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
// v2: 虚拟滚动渲染,支持万级日志流畅查看 // v2: 虚拟滚动渲染,支持万级日志流畅查看
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -46,6 +47,7 @@ interface AuditStatistics {
} }
export function AuditOperationsTab() { export function AuditOperationsTab() {
const { t } = useTranslation();
const _confirm = useConfirm(); const _confirm = useConfirm();
// Filters // Filters
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
@@ -105,11 +107,11 @@ export function AuditOperationsTab() {
const getLogResultBadge = (result: OperationLog['result']) => { const getLogResultBadge = (result: OperationLog['result']) => {
return result === 'success' ? ( return result === 'success' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
成功 {t('common.success')}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10"> <Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
失败 {t('common.failed')}
</Badge> </Badge>
); );
}; };
@@ -163,7 +165,7 @@ export function AuditOperationsTab() {
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input <Input
placeholder="搜索操作日志..." placeholder={t('audit.operations.searchPlaceholder')}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
@@ -176,7 +178,7 @@ export function AuditOperationsTab() {
onChange={(e) => setModuleFilter(e.target.value)} onChange={(e) => setModuleFilter(e.target.value)}
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500" className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
> >
<option value="">全部模块</option> <option value="">{t('audit.operations.allModules')}</option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.value} value={m.value}> <option key={m.value} value={m.value}>
{m.label} {m.label}
@@ -190,7 +192,7 @@ export function AuditOperationsTab() {
onChange={(e) => setActionFilter(e.target.value)} onChange={(e) => setActionFilter(e.target.value)}
className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500" className="h-9 px-3 text-sm rounded-md bg-slate-800/50 border border-slate-700 text-slate-300 focus:outline-none focus:border-blue-500"
> >
<option value="">全部操作</option> <option value="">{t('audit.operations.allActions')}</option>
{actions.map((a) => ( {actions.map((a) => (
<option key={a.value} value={a.value}> <option key={a.value} value={a.value}>
{a.label} {a.label}
@@ -204,7 +206,7 @@ export function AuditOperationsTab() {
onClick={() => refetchLogs()} onClick={() => refetchLogs()}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
刷新 {t('common.refresh')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -212,7 +214,7 @@ export function AuditOperationsTab() {
onClick={handleExport} onClick={handleExport}
> >
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
导出 {t('common.export')}
</Button> </Button>
</div> </div>
@@ -227,7 +229,7 @@ export function AuditOperationsTab() {
) : filteredLogs.length === 0 ? ( ) : filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Search className="h-8 w-8 mb-3 opacity-40" /> <Search className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无操作日志</p> <p className="text-sm">{t('audit.operations.noData')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -236,14 +238,14 @@ export function AuditOperationsTab() {
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10" className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10"
style={{ gridTemplateColumns: GRID_COLS }} style={{ gridTemplateColumns: GRID_COLS }}
> >
<span>时间</span> <span>{t('audit.operations.col.time')}</span>
<span>用户</span> <span>{t('audit.operations.col.user')}</span>
<span>模块</span> <span>{t('audit.operations.col.module')}</span>
<span>操作类型</span> <span>{t('audit.operations.col.actionType')}</span>
<span>资源</span> <span>{t('audit.operations.col.resource')}</span>
<span>IP</span> <span>{t('audit.operations.col.ip')}</span>
<span>结果</span> <span>{t('audit.operations.col.result')}</span>
<span className="text-right">详情</span> <span className="text-right">{t('audit.operations.col.detail')}</span>
</div> </div>
{/* 虚拟滚动列表 */} {/* 虚拟滚动列表 */}
@@ -290,7 +292,7 @@ export function AuditOperationsTab() {
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800" className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => viewDetail(log)} onClick={() => viewDetail(log)}
title="查看详情" title={t('audit.operations.viewDetail')}
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -303,7 +305,7 @@ export function AuditOperationsTab() {
{/* 行数统计 */} {/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800"> <div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'} {t('audit.operations.totalLogs', { count: filteredLogs.length })} {filteredLogs.length > 200 && `· ${t('audit.operations.virtualScroll')}`}
</div> </div>
</> </>
)} )}
@@ -330,6 +332,7 @@ export function AuditOperationsTab() {
// ========================================================================= // =========================================================================
function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) { function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) {
const { t } = useTranslation();
if (!log) return null; if (!log) return null;
return ( return (
@@ -337,7 +340,7 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
<div className="absolute inset-0 bg-black/60" onClick={onClose} /> <div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl"> <div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-100">操作日志详情</h3> <h3 className="text-lg font-semibold text-slate-100">{t('audit.operations.detailTitle')}</h3>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -350,16 +353,16 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<DetailRow label="ID" value={log.id} /> <DetailRow label="ID" value={log.id} />
<DetailRow label="时间" value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} /> <DetailRow label={t('audit.operations.col.time')} value={log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} />
<DetailRow label="用户" value={log.username || '--'} /> <DetailRow label={t('audit.operations.col.user')} value={log.username || '--'} />
<DetailRow label="用户ID" value={log.userId || '--'} /> <DetailRow label={t('audit.operations.detailCol.userId')} value={log.userId || '--'} />
<DetailRow label="模块" value={log.module || '--'} /> <DetailRow label={t('audit.operations.col.module')} value={log.module || '--'} />
<DetailRow label="操作类型" value={log.action || '--'} /> <DetailRow label={t('audit.operations.col.actionType')} value={log.action || '--'} />
<DetailRow label="资源" value={log.resource || '--'} /> <DetailRow label={t('audit.operations.col.resource')} value={log.resource || '--'} />
<DetailRow label="IP地址" value={log.ip || '--'} /> <DetailRow label={t('audit.operations.detailCol.ipAddress')} value={log.ip || '--'} />
<DetailRow label="User-Agent" value={log.userAgent || '--'} /> <DetailRow label={t('audit.operations.detailCol.userAgent')} value={log.userAgent || '--'} />
<DetailRow <DetailRow
label="结果" label={t('audit.operations.col.result')}
value={ value={
<Badge <Badge
variant="outline" variant="outline"
@@ -369,11 +372,11 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
: 'border-red-500/30 text-red-400 bg-red-500/10' : 'border-red-500/30 text-red-400 bg-red-500/10'
} }
> >
{log.result === 'success' ? '成功' : '失败'} {log.result === 'success' ? t('common.success') : t('common.failed')}
</Badge> </Badge>
} }
/> />
{log.message && <DetailRow label="详情" value={log.message} mono />} {log.message && <DetailRow label={t('audit.operations.detailCol.message')} value={log.message} mono />}
</div> </div>
<div className="mt-5 flex justify-end"> <div className="mt-5 flex justify-end">
@@ -382,7 +385,7 @@ function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose:
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={onClose} onClick={onClose}
> >
关闭 {t('common.close')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -416,6 +419,7 @@ function StatisticsDialog({
loading: boolean; loading: boolean;
stats: AuditStatistics | null; stats: AuditStatistics | null;
}) { }) {
const { t } = useTranslation();
if (!open) return null; if (!open) return null;
return ( return (
@@ -423,7 +427,7 @@ function StatisticsDialog({
<div className="absolute inset-0 bg-black/60" onClick={onClose} /> <div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl"> <div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-100">审计统计</h3> <h3 className="text-lg font-semibold text-slate-100">{t('audit.operations.statsTitle')}</h3>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -441,15 +445,15 @@ function StatisticsDialog({
) : stats ? ( ) : stats ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<StatCard label="总日志数" value={String(stats.total_logs ?? '--')} color="blue" /> <StatCard label={t('audit.operations.stats.totalLogs')} value={String(stats.total_logs ?? '--')} color="blue" />
<StatCard label="今日日志" value={String(stats.today_count ?? '--')} color="emerald" /> <StatCard label={t('audit.operations.stats.todayLogs')} value={String(stats.today_count ?? '--')} color="emerald" />
<StatCard label="成功数" value={String(stats.success_count ?? '--')} color="emerald" /> <StatCard label={t('audit.operations.stats.successCount')} value={String(stats.success_count ?? '--')} color="emerald" />
<StatCard label="失败数" value={String(stats.failure_count ?? '--')} color="red" /> <StatCard label={t('audit.operations.stats.failureCount')} value={String(stats.failure_count ?? '--')} color="red" />
</div> </div>
{stats.top_modules && stats.top_modules.length > 0 && ( {stats.top_modules && stats.top_modules.length > 0 && (
<div> <div>
<h4 className="text-xs font-semibold text-slate-400 mb-2">热门模块</h4> <h4 className="text-xs font-semibold text-slate-400 mb-2">{t('audit.operations.topModules')}</h4>
<div className="space-y-1"> <div className="space-y-1">
{stats.top_modules.slice(0, 5).map((m, i) => ( {stats.top_modules.slice(0, 5).map((m, i) => (
<div key={i} className="flex justify-between text-xs"> <div key={i} className="flex justify-between text-xs">
@@ -463,7 +467,7 @@ function StatisticsDialog({
{stats.top_users && stats.top_users.length > 0 && ( {stats.top_users && stats.top_users.length > 0 && (
<div> <div>
<h4 className="text-xs font-semibold text-slate-400 mb-2">活跃用户</h4> <h4 className="text-xs font-semibold text-slate-400 mb-2">{t('audit.operations.topUsers')}</h4>
<div className="space-y-1"> <div className="space-y-1">
{stats.top_users.slice(0, 5).map((u, i) => ( {stats.top_users.slice(0, 5).map((u, i) => (
<div key={i} className="flex justify-between text-xs"> <div key={i} className="flex justify-between text-xs">
@@ -476,7 +480,7 @@ function StatisticsDialog({
)} )}
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10 text-sm">暂无统计数据</p> <p className="text-slate-500 text-center py-10 text-sm">{t('audit.operations.noStats')}</p>
)} )}
<div className="mt-5 flex justify-end"> <div className="mt-5 flex justify-end">
@@ -485,7 +489,7 @@ function StatisticsDialog({
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={onClose} onClick={onClose}
> >
关闭 {t('common.close')}
</Button> </Button>
</div> </div>
</div> </div>
+24 -26
View File
@@ -1,5 +1,6 @@
// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite) // 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite)
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -40,17 +41,8 @@ function normalizeLoginLog(raw: JsonData): LoginLog {
}; };
} }
function getLoginTypeLabel(type: string) {
switch (type) {
case 'password': return '密码登录';
case 'ldap': return 'LDAP';
case 'token': return 'Token';
case 'oauth': return 'OAuth';
default: return type || '密码登录';
}
}
export function LoginLogsTab() { export function LoginLogsTab() {
const { t } = useTranslation();
const [logs, setLogs] = useState<LoginLog[]>([]); const [logs, setLogs] = useState<LoginLog[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -74,18 +66,24 @@ export function LoginLogsTab() {
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
const getLoginTypeLabel = (type: string) => {
const key = `audit.login.type.${type}`;
const label = t(key);
return label === key ? (type || t('audit.login.type.password')) : label;
};
const columns: DataTableColumn<LoginLog>[] = [ const columns: DataTableColumn<LoginLog>[] = [
{ {
key: 'login_at', header: '登录时间', sortable: true, width: 180, key: 'login_at', header: t('audit.login.col.loginTime'), sortable: true, width: 180,
accessor: (r) => r.login_at, accessor: (r) => r.login_at,
render: (r) => <span className="text-sm text-[var(--dg-text-secondary)]">{r.login_at ? new Date(r.login_at).toLocaleString() : '—'}</span>, render: (r) => <span className="text-sm text-[var(--dg-text-secondary)]">{r.login_at ? new Date(r.login_at).toLocaleString() : '—'}</span>,
}, },
{ {
key: 'username', header: '用户', sortable: true, width: 120, key: 'username', header: t('audit.login.col.user'), sortable: true, width: 120,
render: (r) => <span className="text-sm text-[var(--dg-text-primary)] font-medium">{r.username || '—'}</span>, render: (r) => <span className="text-sm text-[var(--dg-text-primary)] font-medium">{r.username || '—'}</span>,
}, },
{ {
key: 'login_type', header: '登录方式', width: 100, key: 'login_type', header: t('audit.login.col.loginType'), width: 100,
render: (r) => ( render: (r) => (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 text-xs"> <Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 text-xs">
{getLoginTypeLabel(r.login_type)} {getLoginTypeLabel(r.login_type)}
@@ -93,11 +91,11 @@ export function LoginLogsTab() {
), ),
}, },
{ {
key: 'ip', header: 'IP', width: 140, key: 'ip', header: t('audit.login.col.ip'), width: 140,
render: (r) => <code className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">{r.ip || '—'}</code>, render: (r) => <code className="text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">{r.ip || '—'}</code>,
}, },
{ {
key: 'location', header: '归属地', width: 140, key: 'location', header: t('audit.login.col.location'), width: 140,
render: (r) => ( render: (r) => (
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"> <span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
<Globe className="h-3 w-3" /> <Globe className="h-3 w-3" />
@@ -106,7 +104,7 @@ export function LoginLogsTab() {
), ),
}, },
{ {
key: 'browser', header: '浏览器/OS', key: 'browser', header: t('audit.login.col.browser'),
render: (r) => ( render: (r) => (
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1" title={r.user_agent}> <span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1" title={r.user_agent}>
<Monitor className="h-3 w-3 flex-shrink-0" /> <Monitor className="h-3 w-3 flex-shrink-0" />
@@ -119,12 +117,12 @@ export function LoginLogsTab() {
), ),
}, },
{ {
key: 'status', header: '状态', sortable: true, width: 80, align: 'center', key: 'status', header: t('audit.login.col.status'), sortable: true, width: 80, align: 'center',
render: (r) => r.status === 'success' ? ( render: (r) => r.status === 'success' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">成功</Badge> <Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">{t('common.success')}</Badge>
) : ( ) : (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10" title={r.fail_reason || undefined}> <Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10" title={r.fail_reason || undefined}>
{r.fail_reason || '失败'} {r.fail_reason || t('common.failed')}
</Badge> </Badge>
), ),
}, },
@@ -136,9 +134,9 @@ export function LoginLogsTab() {
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{[ {[
{ value: '', label: '全部' }, { value: '', label: t('audit.login.all') },
{ value: 'success', label: '成功' }, { value: 'success', label: t('common.success') },
{ value: 'fail', label: '失败' }, { value: 'fail', label: t('common.failed') },
].map(({ value, label }) => ( ].map(({ value, label }) => (
<Button <Button
key={value || 'all'} key={value || 'all'}
@@ -162,7 +160,7 @@ export function LoginLogsTab() {
onClick={fetchLogs} onClick={fetchLogs}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
刷新 {t('common.refresh')}
</Button> </Button>
</div> </div>
@@ -175,7 +173,7 @@ export function LoginLogsTab() {
rowKey={(r) => r.id} rowKey={(r) => r.id}
loading={loading} loading={loading}
searchable searchable
searchPlaceholder="搜索用户/IP/归属地..." searchPlaceholder={t('audit.login.searchPlaceholder')}
searchKeys={['username', 'ip', 'location', 'user_agent']} searchKeys={['username', 'ip', 'location', 'user_agent']}
sortable sortable
pagination pagination
@@ -183,11 +181,11 @@ export function LoginLogsTab() {
exportable exportable
exportFilename="login-logs.csv" exportFilename="login-logs.csv"
columnToggle columnToggle
emptyText="暂无登录日志" emptyText={t('audit.login.noData')}
emptyRender={() => ( emptyRender={() => (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<LogIn className="h-8 w-8 mb-3 text-[var(--dg-text-muted)] opacity-40" /> <LogIn className="h-8 w-8 mb-3 text-[var(--dg-text-muted)] opacity-40" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无登录日志</p> <p className="text-sm text-[var(--dg-text-muted)]">{t('audit.login.noData')}</p>
</div> </div>
)} )}
/> />
+26 -20
View File
@@ -1,6 +1,7 @@
// 终端审计 Tab — 子标签编排 // Logan.Li // 终端审计 Tab — 子标签编排 // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Activity, Monitor, Search } from 'lucide-react'; import { Activity, Monitor, Search } from 'lucide-react';
import { ActiveSessionsPanel } from './terminal-audit/ActiveSessionsPanel'; import { ActiveSessionsPanel } from './terminal-audit/ActiveSessionsPanel';
@@ -8,34 +9,39 @@ import { HistorySessionsPanel } from './terminal-audit/HistorySessionsPanel';
import { SessionCommandsPanel } from './terminal-audit/SessionCommandsPanel'; import { SessionCommandsPanel } from './terminal-audit/SessionCommandsPanel';
import type { SubTabId } from './terminal-audit/types'; import type { SubTabId } from './terminal-audit/types';
const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [ const subTabIds: SubTabId[] = ['active', 'history', 'commands'];
{ id: 'active', name: '在线会话', icon: Activity }, const subTabIcons: Record<SubTabId, React.ElementType> = {
{ id: 'history', name: '历史会话', icon: Monitor }, active: Activity,
{ id: 'commands', name: '会话命令', icon: Search }, history: Monitor,
]; commands: Search,
};
export function TerminalAuditTab() { export function TerminalAuditTab() {
const { t } = useTranslation();
const [subTab, setSubTab] = useState<SubTabId>('active'); const [subTab, setSubTab] = useState<SubTabId>('active');
const [userToggledOff, setUserToggledOff] = useState(false); const [userToggledOff, setUserToggledOff] = useState(false);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit"> <div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit">
{subTabs.map((tab) => ( {subTabIds.map((id) => {
<button const Icon = subTabIcons[id];
key={tab.id} return (
onClick={() => setSubTab(tab.id)} <button
className={cn( key={id}
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all', onClick={() => setSubTab(id)}
subTab === tab.id className={cn(
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm' 'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all',
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]', subTab === id
)} ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm'
> : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
<tab.icon className="h-4 w-4" /> )}
{tab.name} >
</button> <Icon className="h-4 w-4" />
))} {t(`audit.terminal.tab.${id}`)}
</button>
);
})}
</div> </div>
{subTab === 'active' && ( {subTab === 'active' && (
@@ -1,6 +1,7 @@
// 终端审计 — 在线会话子面板 // Logan.Li // 终端审计 — 在线会话子面板 // Logan.Li
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -48,6 +49,7 @@ function ActiveSessionRow({
onKill: (id: number, username: string) => void; onKill: (id: number, username: string) => void;
onMonitor: (id: number) => void; onMonitor: (id: number) => void;
}) { }) {
const { t } = useTranslation();
const elapsed = useElapsedTimer(session.start_time); const elapsed = useElapsedTimer(session.start_time);
return ( return (
@@ -83,7 +85,7 @@ function ActiveSessionRow({
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800" className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
onClick={() => onKill(session.id, session.username)} onClick={() => onKill(session.id, session.username)}
title="阻断会话" title={t('audit.terminal.killTitle')}
> >
<XOctagon className="h-4 w-4" /> <XOctagon className="h-4 w-4" />
</Button> </Button>
@@ -92,7 +94,7 @@ function ActiveSessionRow({
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800" className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => onMonitor(session.id)} onClick={() => onMonitor(session.id)}
title="实时查看" title={t('audit.terminal.liveMonitor')}
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -103,6 +105,7 @@ function ActiveSessionRow({
} }
export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: ActiveSessionsPanelProps) { export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: ActiveSessionsPanelProps) {
const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
const { killMutation } = useTerminalSessionMutations(); const { killMutation } = useTerminalSessionMutations();
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({ const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
@@ -139,9 +142,9 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
const handleKill = async (id: number, username: string) => { const handleKill = async (id: number, username: string) => {
const ok = await confirm({ const ok = await confirm({
title: '阻断会话', title: t('audit.terminal.killTitle'),
description: `确认强制断开用户「${username}」的当前会话吗?`, description: t('audit.terminal.killConfirm', { user: username }),
confirmText: '阻断', confirmText: t('audit.terminal.killConfirmBtn'),
variant: 'destructive', variant: 'destructive',
}); });
if (!ok) return; if (!ok) return;
@@ -163,7 +166,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input <Input
placeholder="搜索用户/服务器..." placeholder={t('audit.terminal.searchUserServer')}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
@@ -175,7 +178,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
onClick={() => refetch()} onClick={() => refetch()}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
刷新 {t('common.refresh')}
</Button> </Button>
<Button <Button
variant={autoRefresh ? 'default' : 'outline'} variant={autoRefresh ? 'default' : 'outline'}
@@ -188,7 +191,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
if (autoRefresh) onUserToggledOff(); if (autoRefresh) onUserToggledOff();
setAutoRefresh(!autoRefresh); setAutoRefresh(!autoRefresh);
}} }}
title={autoRefresh ? `每 ${REFRESH_SEC}s 自动刷新 · 点击关闭` : '点击开启自动刷新'} title={autoRefresh ? t('audit.terminal.autoRefreshHintOn', { sec: REFRESH_SEC }) : t('audit.terminal.autoRefreshHintOff')}
> >
{autoRefresh ? ( {autoRefresh ? (
<> <>
@@ -198,7 +201,7 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
) : ( ) : (
<> <>
<TimerOff className="h-4 w-4 mr-2" /> <TimerOff className="h-4 w-4 mr-2" />
自动刷新 {t('audit.terminal.autoRefresh')}
</> </>
)} )}
</Button> </Button>
@@ -214,20 +217,20 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Activity className="h-8 w-8 mb-3 opacity-40" /> <Activity className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无在线会话</p> <p className="text-sm">{t('audit.terminal.noActive')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent"> <TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">ID</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.id')}</TableHead>
<TableHead className="text-slate-400">用户</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.user')}</TableHead>
<TableHead className="text-slate-400">目标服务器</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.targetServer')}</TableHead>
<TableHead className="text-slate-400">端口</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.port')}</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.startTime')}</TableHead>
<TableHead className="text-slate-400">时长</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.duration')}</TableHead>
<TableHead className="text-slate-400">状态</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.status')}</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead> <TableHead className="text-slate-400 text-right">{t('audit.terminal.col.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -1,6 +1,7 @@
// 终端审计 — 历史会话子面板 // Logan.Li // 终端审计 — 历史会话子面板 // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -19,6 +20,7 @@ import { useTerminalSessions } from '@/hooks/api';
import { formatDuration, getStatusBadge } from './helpers'; import { formatDuration, getStatusBadge } from './helpers';
export function HistorySessionsPanel() { export function HistorySessionsPanel() {
const { t } = useTranslation();
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({ const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
page: 1, page: 1,
page_size: 100, page_size: 100,
@@ -58,7 +60,7 @@ export function HistorySessionsPanel() {
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input <Input
placeholder="搜索用户/服务器/IP..." placeholder={t('audit.terminal.searchUserServerIp')}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
@@ -70,7 +72,7 @@ export function HistorySessionsPanel() {
onClick={() => refetch()} onClick={() => refetch()}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
刷新 {t('common.refresh')}
</Button> </Button>
</div> </div>
@@ -84,21 +86,21 @@ export function HistorySessionsPanel() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Monitor className="h-8 w-8 mb-3 opacity-40" /> <Monitor className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无历史会话</p> <p className="text-sm">{t('audit.terminal.noHistory')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent"> <TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">ID</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.id')}</TableHead>
<TableHead className="text-slate-400">用户</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.user')}</TableHead>
<TableHead className="text-slate-400">目标服务器</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.targetServer')}</TableHead>
<TableHead className="text-slate-400">端口</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.port')}</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.startTime')}</TableHead>
<TableHead className="text-slate-400">结束时间</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.endTime')}</TableHead>
<TableHead className="text-slate-400">时长</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.duration')}</TableHead>
<TableHead className="text-slate-400">状态</TableHead> <TableHead className="text-slate-400">{t('audit.terminal.col.status')}</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead> <TableHead className="text-slate-400 text-right">{t('audit.terminal.col.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -140,7 +142,7 @@ export function HistorySessionsPanel() {
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800" className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => openReplay(s.id)} onClick={() => openReplay(s.id)}
title="回放" title={t('audit.terminal.replay')}
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
</Button> </Button>
@@ -149,7 +151,7 @@ export function HistorySessionsPanel() {
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800" className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
onClick={() => handleDownload(s.id)} onClick={() => handleDownload(s.id)}
title="下载录制" title={t('audit.terminal.downloadRecord')}
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
@@ -2,6 +2,7 @@
// v2: 虚拟滚动,支持万级命令记录流畅查看 // v2: 虚拟滚动,支持万级命令记录流畅查看
import { useMemo, useState, useRef } from 'react'; import { useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,6 +14,7 @@ import { formatSize } from './helpers';
const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr)'; const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr)';
export function SessionCommandsPanel() { export function SessionCommandsPanel() {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [sessionIdInput, setSessionIdInput] = useState(''); const [sessionIdInput, setSessionIdInput] = useState('');
@@ -53,14 +55,14 @@ export function SessionCommandsPanel() {
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input <Input
placeholder="搜索用户/命令..." placeholder={t('audit.terminal.searchUserCommand')}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/> />
</div> </div>
<Input <Input
placeholder="会话 ID(可选)" placeholder={t('audit.terminal.sessionIdPlaceholder')}
value={sessionIdInput} value={sessionIdInput}
onChange={(e) => setSessionIdInput(e.target.value)} onChange={(e) => setSessionIdInput(e.target.value)}
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
@@ -71,7 +73,7 @@ export function SessionCommandsPanel() {
onClick={() => refetch()} onClick={() => refetch()}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
刷新 {t('common.refresh')}
</Button> </Button>
</div> </div>
@@ -85,7 +87,7 @@ export function SessionCommandsPanel() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="flex flex-col items-center justify-center py-16 text-slate-500">
<Search className="h-8 w-8 mb-3 opacity-40" /> <Search className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">暂无会话命令记录</p> <p className="text-sm">{t('audit.terminal.noCommands')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -94,11 +96,11 @@ export function SessionCommandsPanel() {
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400" className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
style={{ gridTemplateColumns: GRID_COLS }} style={{ gridTemplateColumns: GRID_COLS }}
> >
<span>时间</span> <span>{t('audit.terminal.col.startTime')}</span>
<span>用户</span> <span>{t('audit.terminal.col.user')}</span>
<span>命令</span> <span>{t('audit.terminal.col.command')}</span>
<span>输出大小</span> <span>{t('audit.terminal.col.outputSize')}</span>
<span>关联会话</span> <span>{t('audit.terminal.col.sessionRef')}</span>
</div> </div>
{/* 虚拟滚动列表 */} {/* 虚拟滚动列表 */}
@@ -141,7 +143,7 @@ export function SessionCommandsPanel() {
{/* 行数统计 */} {/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800"> <div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filtered.length} 条命令 {filtered.length > 200 && '· 虚拟滚动渲染'} {t('audit.terminal.totalCommands', { count: filtered.length })} {filtered.length > 200 && `· ${t('audit.terminal.virtualScroll')}`}
</div> </div>
</> </>
)} )}
@@ -1,16 +1,17 @@
// 终端审计 — 展示辅助 // Logan.Li // 终端审计 — 展示辅助 // Logan.Li
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import i18n from '@/i18n';
export function formatDuration(sec: number): string { export function formatDuration(sec: number): string {
if (!sec || sec < 0) return '--'; if (!sec || sec < 0) return '--';
if (sec < 60) return `${sec}秒`; if (sec < 60) return i18n.t('common.duration.sec', { count: sec });
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = sec % 60; const s = sec % 60;
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`; if (m < 60) return s > 0 ? i18n.t('common.duration.minSec', { m, s }) : i18n.t('common.duration.min', { count: m });
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
const rm = m % 60; const rm = m % 60;
return `${h}时${rm > 0 ? `${rm}分` : ''}`; return rm > 0 ? i18n.t('common.duration.hourMin', { h, m: rm }) : i18n.t('common.duration.hour', { count: h });
} }
export function formatSize(bytes: number): string { export function formatSize(bytes: number): string {
@@ -25,20 +26,20 @@ export function getStatusBadge(status: string) {
return ( return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
活动中 {i18n.t('audit.terminal.status.active')}
</Badge> </Badge>
); );
} }
if (status === 'failed') { if (status === 'failed') {
return ( return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10"> <Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
失败 {i18n.t('audit.terminal.status.failed')}
</Badge> </Badge>
); );
} }
return ( return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10"> <Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
已关闭 {i18n.t('audit.terminal.status.closed')}
</Badge> </Badge>
); );
} }