fix: 审核引擎优化 + 执行结果结构化展示

后端审核规则修复:
- Redis SEC-001: SET/DEL等常规命令不再误报AUTH要求,改为仅对管理级命令WARNING
- Redis SEC-003: 降级为INFO,排除已由CMD-001拦截的FLUSHDB/FLUSHALL
- Redis key提取: 排除CONFIG/ACL等管理命令,不再把子命令参数误认为key名
- MySQL SEC-006: 新增GRANT ALL/REVOKE ALL全局权限拦截
- MySQL SEC-007: 新增LOAD DATA/LOAD XML拦截
- MySQL SEC-008: 新增SLEEP()/BENCHMARK()时间盲注拦截
- MySQL SEC-009: 新增INTO OUTFILE/DUMPFILE文件写入拦截

前端执行结果结构化:
- TicketDetailPage执行结果从原始JSON改为结构化展示
- 解析JSON展示: 状态徽章+影响行数+耗时+验证状态
- Errors/Warnings逐条渲染: 红色错误框+黄色警告框+具体消息
- 删除废弃的executeStats/renderExecuteStats

其他: 审核规则6分组/慢查询移除/查询数据重命名/AuditLogs修复
测试: 1040 passed, TSC零错误
This commit is contained in:
logan committed 2026-05-24 01:31:33 +08:00
1 parent 9f56e866f2
commit 4ac2528f8e
19 files changed
+1202 -325

No files matched your search

+2 -2
View File
@@ -493,9 +493,9 @@ export default function AuditLogsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{log.username.charAt(0).toUpperCase()}
{(log.username || '?').charAt(0).toUpperCase()}
</div>
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
<span className="text-[var(--dg-text-primary)]">{log.username || '-'}</span>
</div>
</td>
<td className="px-4 py-3">
+16 -27
View File
@@ -26,7 +26,7 @@ interface AuditRule {
}
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL';
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL' | 'security' | 'performance';
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
/* ------------------------------------------------------------------ */
@@ -51,6 +51,8 @@ const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType;
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
security: { label: '安全规则', icon: AlertTriangle, color: 'text-red-400 bg-red-500/15' },
performance: { label: '性能规则', icon: Info, color: 'text-cyan-400 bg-cyan-500/15' },
};
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
@@ -59,34 +61,17 @@ const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: strin
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
};
const DDL_KEYWORDS = [
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
' Validator', 'CAPPED', 'SCHEMA',
];
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getRuleGroup(name: string): RuleGroup {
const upper = name.toUpperCase();
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
// MongoDB security rules -> DCL, performance rules -> DQL
if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL';
if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL';
// Default to DDL for structural rules, DML for data rules
if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML';
const VALID_GROUPS: RuleGroup[] = ['DDL', 'DML', 'DCL', 'DQL', 'security', 'performance'];
function getRuleGroup(rule: AuditRule): RuleGroup {
const rt = rule.rule_type;
if (VALID_GROUPS.includes(rt as RuleGroup)) return rt as RuleGroup;
// Fallback: map unknown types
if (rt === 'safety' || rt === 'naming' || rt === 'syntax') return 'DDL';
return 'DDL';
}
@@ -99,7 +84,7 @@ function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): Au
case 'name':
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
case 'type':
return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name)));
return sorted.sort((a, b) => getRuleGroup(a).localeCompare(getRuleGroup(b)));
default:
return sorted;
}
@@ -239,9 +224,11 @@ export default function AuditRulesPage() {
DML: [],
DCL: [],
DQL: [],
security: [],
performance: [],
};
for (const rule of filteredRules) {
const group = getRuleGroup(rule.name);
const group = getRuleGroup(rule);
groups[group].push(rule);
}
return groups;
@@ -580,8 +567,10 @@ export default function AuditRulesPage() {
<div className="space-y-6">
{renderRuleGroup('DDL', groupedRules.DDL)}
{renderRuleGroup('DML', groupedRules.DML)}
{renderRuleGroup('security', groupedRules.security)}
{renderRuleGroup('DCL', groupedRules.DCL)}
{renderRuleGroup('DQL', groupedRules.DQL)}
{renderRuleGroup('performance', groupedRules.performance)}
</div>
)}
</div>
+58 -32
View File
@@ -11,6 +11,7 @@ import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { instanceApi } from '@/api/instance';
import EditInstanceDialog from '@/components/EditInstanceDialog';
/* ------------------------------------------------------------------ */
/* Types */
@@ -41,11 +42,14 @@ type ViewMode = 'grid' | 'list';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* DB Brand Icons */
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
postgres: { icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
redis: { icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
};
const envLabel: Record<string, string> = {
@@ -132,6 +136,8 @@ export default function InstancePage() {
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
@@ -242,6 +248,28 @@ export default function InstancePage() {
);
}
if (showEdit && editingInstance) {
return (
<>
<InstanceDetail
instance={editingInstance}
onBack={() => { setView('grid'); setShowEdit(false); setEditingInstance(null); }}
config={dbTypeConfig[editingInstance.db_type] || dbTypeConfig.mysql}
canEdit={canEdit}
canDelete={canDelete}
onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }}
onEdit={() => {}}
/>
<EditInstanceDialog
instance={editingInstance}
open={showEdit}
onClose={() => { setShowEdit(false); setEditingInstance(null); }}
onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }}
/>
</>
);
}
const selectedConfig = selectedInstance
? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql)
: dbTypeConfig.mysql;
@@ -255,6 +283,7 @@ export default function InstancePage() {
canEdit={canEdit}
canDelete={canDelete}
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }}
/>
);
}
@@ -428,7 +457,7 @@ export default function InstancePage() {
) : filtered.length === 0 ? (
<EmptyState type="search" />
) : layoutMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filtered.map(instance => {
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
const IconComp = ic.icon;
@@ -438,7 +467,7 @@ export default function InstancePage() {
return (
<div
key={instance.id}
className={`dg-card p-5 cursor-pointer dg-card-hover group relative transition-all ${
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
isSelected ? 'ring-2 ring-blue-500/50' : ''
}`}
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
@@ -456,8 +485,8 @@ export default function InstancePage() {
</div>
<div className="flex items-start justify-between mb-4 pl-6">
<div className={`w-11 h-11 rounded-xl ${ic.bg} flex items-center justify-center`}>
<IconComp className={`w-6 h-6 ${ic.color}`} />
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
<IconComp className="w-5 h-5 object-contain" />
</div>
<div className="flex items-center gap-2">
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
@@ -472,14 +501,14 @@ export default function InstancePage() {
</div>
</div>
<h3 className="text-base font-semibold mb-1 truncate">{instance.name}</h3>
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3">
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
{ic.label} {instance.version || ''}
</p>
{/* Health Score */}
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
<div className="flex-1">
<div className="flex items-center justify-between">
@@ -495,35 +524,33 @@ export default function InstancePage() {
</div>
{/* Metrics */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="grid grid-cols-3 gap-1.5 text-center">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.connections}</span>
<span className="text-[11px] font-semibold">{instance.connections}</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">连接</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
</div>
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">QPS</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
</div>
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.latency}ms</span>
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">延迟</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
</div>
</div>
{instance.description && (
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mt-3">{instance.description}</p>
)}
<div className="mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-blue-400 flex items-center gap-1">
查看详情
<ChevronRight className="w-3 h-3" />
@@ -584,7 +611,7 @@ export default function InstancePage() {
<td className="px-4 py-3 font-medium">{instance.name}</td>
<td className="px-4 py-3">
<span className={`flex items-center gap-1.5 ${ic.color}`}>
<IconComp className="w-4 h-4" />
<IconComp className="w-4 h-4 object-contain" />
<span className="text-xs">{ic.label}</span>
</span>
</td>
@@ -633,10 +660,10 @@ export default function InstancePage() {
/* ------------------------------------------------------------------ */
/* InstanceDetail */
/* ------------------------------------------------------------------ */
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }: {
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: {
instance: InstanceItem; onBack: () => void;
config: { icon: React.ElementType; color: string; bg: string };
canEdit: boolean; canDelete: boolean; onDelete: () => void;
canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void;
}) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
@@ -694,7 +721,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
{testing ? '测试中...' : '测试连接'}
</button>
{canEdit && (
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Edit2 className="w-4 h-4" />
编辑
</button>
@@ -845,8 +872,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
);
}
/* ------------------------------------------------------------------ */
/* DB Brand Icons */
/* ------------------------------------------------------------------ */
function MySQLIcon({ className }: { className?: string }) {
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
+1 -1
View File
@@ -170,7 +170,7 @@ export default function QueriesPage() {
return (
<div className="space-y-5">
<PageHeader
title="查询审计"
title="查询数据"
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
/>
+1 -1
View File
@@ -32,7 +32,7 @@ const PERMISSION_PRESETS: PermissionPreset[] = [
{
key: 'dba',
name: 'DBA 模板',
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
icon: Database,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
+188 -77
View File
@@ -216,19 +216,6 @@ export default function TicketDetailPage() {
/* Parse execute result for visualization */
/* ---------------------------------------------------------------- */
const executeStats = useMemo(() => {
if (!ticket?.execute_result) return null;
// Try to extract affected rows and duration from execute result
const result = ticket.execute_result;
const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i);
const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i);
return {
affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined),
durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined),
raw: result,
};
}, [ticket]);
/* ---------------------------------------------------------------- */
/* Loading & Error states */
/* ---------------------------------------------------------------- */
@@ -442,39 +429,6 @@ export default function TicketDetailPage() {
);
};
const renderExecuteStats = () => {
if (!executeStats) return null;
return (
<div className="mt-3 grid grid-cols-2 gap-3">
{executeStats.affectedRows !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<BarChart3 className="w-3.5 h-3.5" />
影响行数
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.affectedRows.toLocaleString()}
</div>
</div>
)}
{executeStats.durationMs !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<Clock className="w-3.5 h-3.5" />
执行耗时
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.durationMs >= 1000
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
: `${Math.round(executeStats.durationMs)}ms`}
</div>
</div>
)}
</div>
);
};
const renderRelatedTickets = () => {
if (relatedTickets.length === 0) return null;
@@ -624,22 +578,90 @@ export default function TicketDetailPage() {
</div>
{/* Audit result */}
{ticket.audit_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
{ticket.audit_result}
{ticket.audit_result && (() => {
let audit: any = null;
try { audit = typeof ticket.audit_result === 'string' ? JSON.parse(ticket.audit_result) : ticket.audit_result; } catch { audit = null; }
if (!audit || !audit.findings) {
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{ticket.audit_result}</p>
</div>
);
}
const errorCount = audit.findings.filter((f: any) => f.level === 'error').length;
const warningCount = audit.findings.filter((f: any) => f.level === 'warning').length;
const infoCount = audit.findings.filter((f: any) => f.level === 'info').length;
const scoreColor = audit.score >= 90 ? 'text-green-400' : audit.score >= 60 ? 'text-amber-400' : 'text-red-400';
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> SQL 审核结果
</h3>
<div className="flex items-center gap-3">
<span className={`text-xl font-bold ${scoreColor}`}>{audit.score ?? '-'}</span>
<span className="text-xs text-[var(--dg-text-muted)]">分</span>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</div>
</button>
{/* Summary badges */}
<div className="mt-3 flex items-center gap-2 flex-wrap">
{audit.passed ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-green-500/15 text-green-400">
<CheckCircle className="w-3 h-3" /> 审核通过
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-red-500/15 text-red-400">
<XCircle className="w-3 h-3" /> 存在阻断项
</span>
)}
{errorCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-red-500/15 text-red-400">{errorCount} 错误</span>}
{warningCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-amber-500/15 text-amber-400">{warningCount} 警告</span>}
{infoCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-blue-500/15 text-blue-400">{infoCount} 提示</span>}
</div>
{/* Findings table */}
{showFullAudit && audit.findings.length > 0 && (
<div className="mt-4 space-y-2">
{audit.findings.map((f: any, idx: number) => {
const levelConfig: Record<string, { label: string; color: string; icon: any }> = {
error: { label: '错误', color: 'text-red-400 bg-red-500/15', icon: XCircle },
warning: { label: '警告', color: 'text-amber-400 bg-amber-500/15', icon: AlertTriangle },
info: { label: '提示', color: 'text-blue-400 bg-blue-500/15', icon: CheckCircle },
};
const lc = levelConfig[f.level] || levelConfig.info;
const LevelIcon = lc.icon;
return (
<div key={idx} className={`p-3 rounded-lg border border-[var(--dg-border)] ${f.level === 'error' ? 'border-red-500/20' : ''}`}>
<div className="flex items-center gap-2 mb-1">
<LevelIcon className={`w-3.5 h-3.5 ${lc.color.split(' ')[0]}`} />
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${lc.color}`}>{lc.label}</span>
{f.rule && <span className="text-xs text-[var(--dg-text-muted)] font-mono">{f.rule}</span>}
</div>
<p className="text-sm text-[var(--dg-text-primary)] pl-6">{f.message}</p>
</div>
);
})}
</div>
)}
{/* Summary text */}
{!showFullAudit && audit.summary && (
<p className="mt-2 text-xs text-[var(--dg-text-muted)]">{audit.summary}</p>
)}
</div>
</div>
)}
);
})()}
{/* Rollback SQL */}
{ticket.rollback_sql && (
@@ -658,23 +680,112 @@ export default function TicketDetailPage() {
)}
{/* Execute result */}
{ticket.execute_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{renderExecuteStats()}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
{ticket.execute_result && (() => {
let execResult: any = null;
try { execResult = typeof ticket.execute_result === 'string' ? JSON.parse(ticket.execute_result) : ticket.execute_result; } catch { execResult = null; }
if (!execResult || typeof execResult !== 'object') {
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
</div>
)}
);
}
const isSuccess = execResult.status === 'success';
const StatusIcon = isSuccess ? CheckCircle : XCircle;
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
const statusBg = isSuccess ? 'bg-green-500/15' : 'bg-red-500/15';
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
{/* Status badge + key metrics */}
<div className="flex items-center gap-3 flex-wrap mb-3">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium ${statusBg} ${statusColor}`}>
<StatusIcon className="w-3.5 h-3.5" />
{isSuccess ? '执行成功' : '执行失败'}
</span>
{execResult.affected_rows !== undefined && (
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
影响行数: <span className="font-medium">{execResult.affected_rows}</span>
</span>
)}
{execResult.duration_ms !== undefined && (
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
耗时: <span className="font-medium">{execResult.duration_ms}ms</span>
</span>
)}
{execResult.validation_passed !== undefined && (
<span className={`px-2.5 py-1 rounded text-xs ${execResult.validation_passed ? 'text-green-400 bg-green-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
{execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'}
</span>
)}
</div>
{/* Errors */}
{execResult.errors && execResult.errors.length > 0 && (
<div className="space-y-2 mb-3">
{execResult.errors.map((err: string, idx: number) => (
<div key={idx} className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-1.5 mb-1">
<XCircle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs font-medium text-red-400">错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}</span>
</div>
<p className="text-sm text-red-300 pl-5">{err}</p>
</div>
))}
</div>
)}
{/* Warnings */}
{execResult.warnings && execResult.warnings.length > 0 && (
<div className="space-y-2 mb-3">
{execResult.warnings.map((w: string, idx: number) => (
<div key={idx} className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span className="text-xs font-medium text-amber-400">警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}</span>
</div>
<p className="text-sm text-amber-300 pl-5">{w}</p>
</div>
))}
</div>
)}
{/* Raw output (if present) */}
{execResult.raw_output && (
<div className="mt-3">
<p className="text-xs text-[var(--dg-text-muted)] mb-1">原始输出</p>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto border border-[var(--dg-border-light)]">
{execResult.raw_output}
</pre>
</div>
)}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-3 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
);
})()}
{/* Timeline */}
{renderTimeline()}
+22 -149
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
FileCode, History, Undo2, XCircle as XIcon,
Clock, AlertTriangle, AlertCircle, Info, ChevronLeft,
Plus, Search, Eye, CheckCircle, XCircle, Play,
FileCode, History, XCircle as XIcon,
Clock, AlertTriangle, ChevronLeft,
Filter, X, Loader2, Users, Calendar,
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
} from 'lucide-react';
@@ -24,9 +24,7 @@ interface TicketItem {
instance_id: number;
target_database: string;
sql_content: string;
rollback_sql: string | null;
status: string;
priority: string;
applicant_id: number;
applicant_name: string | null;
approver_id: number | null;
@@ -62,11 +60,7 @@ type DateRange = 'all' | 'today' | 'week' | 'month';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const priorityConfig: Record<string, { label: string; color: string; bg: string; border: string; icon: React.ElementType }> = {
high: { label: '高', color: 'text-red-400', bg: 'bg-red-500/15', border: 'border-red-500/30', icon: AlertCircle },
medium: { label: '中', color: 'text-yellow-400', bg: 'bg-yellow-500/15', border: 'border-yellow-500/30', icon: AlertTriangle },
low: { label: '低', color: 'text-blue-400', bg: 'bg-blue-500/15', border: 'border-blue-500/30', icon: Info },
};
const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
@@ -74,14 +68,13 @@ const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
/* Helpers */
/* ------------------------------------------------------------------ */
function exportTicketsToCSV(tickets: TicketItem[]): string {
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '优先级', '状态', '创建时间', '变更原因'];
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '状态', '创建时间', '变更原因'];
const rows = tickets.map(t => ({
工单号: `TK-${t.id}`,
标题: t.title,
数据库类型: t.db_type.toUpperCase(),
目标数据库: t.target_database,
申请人: t.applicant_name || '-',
优先级: t.priority === 'high' ? '高' : t.priority === 'medium' ? '中' : '低',
状态: statusMap[t.status] || t.status,
创建时间: new Date(t.create_time).toLocaleString(),
变更原因: t.reason || '-',
@@ -144,8 +137,6 @@ function TableRowSkeleton() {
<td className="px-5 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-10 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-12 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
</tr>
@@ -163,7 +154,6 @@ export default function TicketsPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dbTypeFilter, setDbTypeFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [dateRange, setDateRange] = useState<DateRange>('all');
const [applicantFilter, setApplicantFilter] = useState('');
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
@@ -177,7 +167,7 @@ export default function TicketsPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchApproving, setBatchApproving] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [sortField, setSortField] = useState<'create_time' | 'priority'>('create_time');
const [sortField, setSortField] = useState<'create_time'>('create_time');
const [sortAsc, setSortAsc] = useState(false);
const fetchTickets = useCallback(async () => {
@@ -223,11 +213,6 @@ export default function TicketsPage() {
}
/* Client-side sort */
result.sort((a, b) => {
if (sortField === 'priority') {
const order = { high: 0, medium: 1, low: 2 };
const cmp = (order[a.priority as keyof typeof order] ?? 3) - (order[b.priority as keyof typeof order] ?? 3);
return sortAsc ? cmp : -cmp;
}
return sortAsc
? new Date(a.create_time).getTime() - new Date(b.create_time).getTime()
: new Date(b.create_time).getTime() - new Date(a.create_time).getTime();
@@ -310,7 +295,7 @@ export default function TicketsPage() {
);
}
const hasActiveFilters = search || statusFilter || dbTypeFilter || priorityFilter || dateRange !== 'all' || applicantFilter;
const hasActiveFilters = search || statusFilter || dbTypeFilter || dateRange !== 'all' || applicantFilter;
return (
<div className="space-y-5">
@@ -401,16 +386,7 @@ export default function TicketsPage() {
<option value="postgres">PostgreSQL</option>
<option value="redis">Redis</option>
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={priorityFilter}
onChange={e => { setPriorityFilter(e.target.value); setPage(1); }}
>
<option value="">全部优先级</option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={dateRange}
@@ -446,12 +422,7 @@ export default function TicketsPage() {
<button onClick={() => setDbTypeFilter('')}><X className="w-3 h-3" /></button>
</span>
)}
{priorityFilter && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
优先级: {priorityConfig[priorityFilter]?.label || priorityFilter}
<button onClick={() => setPriorityFilter('')}><X className="w-3 h-3" /></button>
</span>
)}
{dateRange !== 'all' && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
<Calendar className="w-3 h-3" />
@@ -460,7 +431,7 @@ export default function TicketsPage() {
</span>
)}
<button
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setPriorityFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
className="text-xs text-red-400 hover:text-red-300 ml-auto"
>
清除全部
@@ -510,7 +481,6 @@ export default function TicketsPage() {
<th className="text-left px-5 py-3 font-semibold">标题</th>
<th className="text-left px-5 py-3 font-semibold">数据库</th>
<th className="text-left px-5 py-3 font-semibold">申请人</th>
<th className="text-left px-5 py-3 font-semibold">优先级</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
<th className="text-left px-5 py-3 font-semibold">操作</th>
@@ -546,15 +516,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">标题</th>
<th className="text-left px-4 py-3 font-semibold">数据库</th>
<th className="text-left px-4 py-3 font-semibold">申请人</th>
<th className="text-left px-4 py-3 font-semibold">
<button
onClick={() => { setSortField('priority'); setSortAsc(!sortAsc); }}
className="flex items-center gap-1 hover:text-blue-400 transition-colors"
>
优先级
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button
@@ -570,8 +532,6 @@ export default function TicketsPage() {
</thead>
<tbody>
{clientFilteredTickets.map(ticket => {
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
const PIcon = pConfig.icon;
const isSelected = selectedIds.has(ticket.id);
return (
<tr
@@ -599,12 +559,7 @@ export default function TicketsPage() {
{ticket.applicant_name || '-'}
</div>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
<PIcon className="w-3 h-3" />
{pConfig.label}
</span>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
@@ -688,14 +643,10 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
const [instanceId, setInstanceId] = useState('');
const [targetDatabase, setTargetDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [rollbackSql, setRollbackSql] = useState('');
const [reason, setReason] = useState('');
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
const [submitting, setSubmitting] = useState(false);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number }>>([]);
const [error, setError] = useState('');
const [charCount, setCharCount] = useState({ change: 0, rollback: 0 });
useEffect(() => {
instanceApi.list().then(res => {
@@ -718,9 +669,7 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
instance_id: Number(instanceId),
target_database: targetDatabase,
sql_content: sqlContent,
rollback_sql: rollbackSql || undefined,
reason,
priority,
});
onCreated();
} catch (e: unknown) {
@@ -729,25 +678,11 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
} finally {
setSubmitting(false);
}
}, [title, dbType, instanceId, targetDatabase, sqlContent, rollbackSql, reason, priority, onCreated]);
}, [title, dbType, instanceId, targetDatabase, sqlContent, reason, onCreated]);
const handleSqlChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
if (sqlTab === 'change') {
setSqlContent(val);
setCharCount(prev => ({ ...prev, change: val.length }));
} else {
setRollbackSql(val);
setCharCount(prev => ({ ...prev, rollback: val.length }));
}
}, [sqlTab]);
const currentSql = sqlTab === 'change' ? sqlContent : rollbackSql;
const priorityOptions: Array<{ value: 'high' | 'medium' | 'low'; label: string }> = [
{ value: 'high', label: '高' },
{ value: 'medium', label: '中' },
{ value: 'low', label: '低' },
];
return (
<div className="space-y-5 max-w-5xl">
@@ -807,62 +742,17 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
</div>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<div className="flex gap-3">
{priorityOptions.map(p => {
const pConf = priorityConfig[p.value];
return (
<button
key={p.value}
onClick={() => setPriority(p.value)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm border transition-all font-medium ${
priority === p.value
? `${pConf.bg} ${pConf.color} ${pConf.border}`
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'
}`}
>
<pConf.icon className="w-4 h-4" />
{p.label}
</button>
);
})}
</div>
</div>
{/* SQL Editor */}
<div>
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
<div className="dg-card overflow-hidden">
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<button
onClick={() => setSqlTab('change')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<FileCode className="w-3.5 h-3.5" />
变更 SQL
</button>
<button
onClick={() => setSqlTab('rollback')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<RotateCcw className="w-3.5 h-3.5" />
回滚 SQL
</button>
<span className="ml-auto text-xs text-[var(--dg-text-muted)]">
{charCount[sqlTab]} 字符
</span>
</div>
<textarea
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
placeholder={sqlTab === 'change' ? "UPDATE users SET status = 'active' WHERE id = 1;" : "-- 请输入回滚 SQL"}
value={currentSql}
onChange={handleSqlChange}
placeholder="UPDATE users SET status = 'active' WHERE id = 1;"
value={sqlContent}
onChange={e => setSqlContent(e.target.value)}
/>
</div>
</div>
@@ -914,7 +804,6 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
}) {
const [loading, setLoading] = useState(false);
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
setLoading(true);
@@ -956,10 +845,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
<div>
<h1 className="text-xl font-semibold flex items-center gap-3 flex-wrap">
{ticket.title}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
<pConfig.icon className="w-3 h-3" />
{pConfig.label}优先级
</span>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status}
</span>
@@ -1061,7 +947,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
{ label: '目标数据库', value: ticket.target_database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}><pConfig.icon className="w-3 h-3" />{pConfig.label}</span> },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ label: '变更原因', value: ticket.reason || '-' },
].map((item, idx) => (
@@ -1087,20 +973,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
</Card>
</div>
{/* Rollback SQL */}
{ticket.rollback_sql && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Undo2 className="w-4 h-4 text-purple-400" />
回滚 SQL
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap max-h-[300px] overflow-y-auto">{ticket.rollback_sql}</pre>
</CardContent>
</Card>
)}
{/* Audit Record */}
{ticket.audit_result && (