后端审核规则修复: - 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零错误
798 lines
34 KiB
TypeScript
798 lines
34 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
|
|
Play, RotateCcw, X, Shield, User, Calendar,
|
|
MessageSquare, Loader2, AlertTriangle, Database,
|
|
BarChart3, ChevronDown, ChevronUp, Link2,
|
|
} from 'lucide-react';
|
|
import { ticketApi } from '@/api/ticket';
|
|
import { statusMap, statusColorMap } from '@/lib/mock';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import SqlEditor from '@/components/SqlEditor';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface TicketData {
|
|
id: number;
|
|
title: string;
|
|
db_type: string;
|
|
instance_id: number;
|
|
instance_name?: string;
|
|
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;
|
|
approver_name: string | null;
|
|
reason: string | null;
|
|
audit_result: string | null;
|
|
execute_result: string | null;
|
|
affected_rows?: number;
|
|
execute_duration_ms?: number;
|
|
create_time: string;
|
|
review_time: string | null;
|
|
execute_time: string | null;
|
|
}
|
|
|
|
interface TimelineEntry {
|
|
action: string;
|
|
label: string;
|
|
user: string;
|
|
time: string | null;
|
|
detail: string | null;
|
|
}
|
|
|
|
interface RelatedTicket {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
applicant_name: string | null;
|
|
create_time: string;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Constants */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
|
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
|
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
|
EXECUTE_TICKET: { icon: Play, color: 'text-purple-400', bg: 'bg-purple-500/15' },
|
|
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
|
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
|
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
|
};
|
|
|
|
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
|
|
high: { label: '高', color: 'text-red-400 bg-red-500/15' },
|
|
medium: { label: '中', color: 'text-amber-400 bg-amber-500/15' },
|
|
low: { label: '低', color: 'text-green-400 bg-green-500/15' },
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export default function TicketDetailPage() {
|
|
const params = useParams();
|
|
const navigate = useNavigate();
|
|
const auth = useAuth();
|
|
const ticketId = Number(params.id);
|
|
|
|
// -- Data --
|
|
const [ticket, setTicket] = useState<TicketData | null>(null);
|
|
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
|
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
// -- Action states --
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [comment, setComment] = useState('');
|
|
const [showCommentInput, setShowCommentInput] = useState(false);
|
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
|
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
|
|
|
|
// -- UI expansion --
|
|
const [showFullAudit, setShowFullAudit] = useState(false);
|
|
const [showRelated, setShowRelated] = useState(true);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Fetch ticket data */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const fetchTicket = useCallback(async () => {
|
|
try {
|
|
const data = await ticketApi.get(ticketId) as unknown as TicketData;
|
|
setTicket(data);
|
|
setError('');
|
|
} catch {
|
|
setError('工单不存在或加载失败');
|
|
setTicket(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [ticketId]);
|
|
|
|
const fetchTimeline = useCallback(async () => {
|
|
try {
|
|
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
|
|
setTimeline(res?.timeline || []);
|
|
} catch {
|
|
setTimeline([]);
|
|
}
|
|
}, [ticketId]);
|
|
|
|
// Mock related tickets fetch
|
|
const fetchRelated = useCallback(async () => {
|
|
// In real implementation: await ticketApi.related(ticketId)
|
|
setRelatedTickets([
|
|
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
|
|
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
|
|
]);
|
|
}, [ticketId]);
|
|
|
|
useEffect(() => {
|
|
if (!isNaN(ticketId)) {
|
|
setLoading(true);
|
|
fetchTicket();
|
|
fetchTimeline();
|
|
fetchRelated();
|
|
}
|
|
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Action handler */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const handleAction = useCallback(async (action: string) => {
|
|
if (!ticket) return;
|
|
|
|
// Reset comment state
|
|
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
|
|
|
|
setActionLoading(action);
|
|
setError('');
|
|
|
|
try {
|
|
switch (action) {
|
|
case 'approve':
|
|
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
|
|
break;
|
|
case 'reject':
|
|
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
|
|
setShowCommentInput(false);
|
|
setComment('');
|
|
break;
|
|
case 'execute':
|
|
await ticketApi.execute(ticketId);
|
|
break;
|
|
case 'rollback':
|
|
await ticketApi.rollback(ticketId);
|
|
setShowRollbackConfirm(false);
|
|
break;
|
|
case 'cancel':
|
|
await ticketApi.cancel(ticketId);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
fetchTicket();
|
|
fetchTimeline();
|
|
} catch (e: unknown) {
|
|
const err = e as { response?: { data?: { detail?: string } } };
|
|
setError(err?.response?.data?.detail || `操作失败: ${action}`);
|
|
} finally {
|
|
setActionLoading(null);
|
|
setPendingAction(null);
|
|
}
|
|
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Permission checks */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
|
if (!ticket) {
|
|
return { canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
|
}
|
|
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
|
const applicant = ticket.applicant_id === auth.user.id;
|
|
return {
|
|
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
|
canExecute: adminOrDba && ticket.status === 'approved',
|
|
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
|
|
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
|
};
|
|
}, [ticket, auth.user.role, auth.user.id]);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Parse execute result for visualization */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Loading & Error states */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-3">
|
|
<Loader2 className="w-8 h-8 animate-spin" />
|
|
<p className="text-sm">加载中...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !ticket) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-4">
|
|
<AlertTriangle className="w-12 h-12 text-amber-400" />
|
|
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{error || '工单不存在'}</p>
|
|
<button
|
|
onClick={() => navigate('/tickets')}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
|
|
>
|
|
返回列表
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium;
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Render helpers */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const renderActionButtons = () => {
|
|
if (showRollbackConfirm) {
|
|
return (
|
|
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
|
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
|
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleAction('rollback')}
|
|
disabled={!!actionLoading}
|
|
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
|
|
>
|
|
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowRollbackConfirm(false)}
|
|
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
{/* Approve / Reject */}
|
|
{canApprove && (
|
|
<>
|
|
{!showCommentInput ? (
|
|
<>
|
|
<button
|
|
onClick={() => { setPendingAction('approve'); setShowCommentInput(true); }}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
|
|
>
|
|
<CheckCircle className="w-4 h-4" /> 审批通过
|
|
</button>
|
|
<button
|
|
onClick={() => { setPendingAction('reject'); setShowCommentInput(true); }}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
|
|
>
|
|
<XCircle className="w-4 h-4" /> 审批拒绝
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-2 w-full">
|
|
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
<input
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={() => pendingAction && handleAction(pendingAction)}
|
|
disabled={!!actionLoading}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
pendingAction === 'reject'
|
|
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
: 'bg-green-600 hover:bg-green-700 text-white'
|
|
} disabled:opacity-50`}
|
|
>
|
|
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
|
|
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Execute */}
|
|
{canExecute && (
|
|
<button
|
|
onClick={() => handleAction('execute')}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all"
|
|
>
|
|
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
|
执行
|
|
</button>
|
|
)}
|
|
|
|
{/* Rollback */}
|
|
{canRollback && (
|
|
<button
|
|
onClick={() => setShowRollbackConfirm(true)}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
|
|
>
|
|
<RotateCcw className="w-4 h-4" /> 回滚
|
|
</button>
|
|
)}
|
|
|
|
{/* Cancel */}
|
|
{canCancel && (
|
|
<button
|
|
onClick={() => handleAction('cancel')}
|
|
disabled={!!actionLoading}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
|
|
>
|
|
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
|
取消工单
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTimeline = () => {
|
|
if (timeline.length === 0) return null;
|
|
|
|
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-4">
|
|
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
|
</h3>
|
|
<div className="relative pl-8">
|
|
{/* Vertical line */}
|
|
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
|
|
|
{timeline.map((entry, idx) => {
|
|
const config = ACTION_CONFIG[entry.action] || {
|
|
icon: CheckCircle,
|
|
color: 'text-[var(--dg-text-muted)]',
|
|
bg: 'bg-[var(--dg-bg-tertiary)]',
|
|
};
|
|
const Icon = config.icon;
|
|
const isLast = idx === timeline.length - 1;
|
|
|
|
return (
|
|
<div key={idx} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
|
{/* Icon node */}
|
|
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
|
|
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
|
</div>
|
|
|
|
<div className="ml-2">
|
|
{/* Label + User */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium">{entry.label}</span>
|
|
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
|
<User className="w-3 h-3" />
|
|
{entry.user}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Time */}
|
|
{entry.time && (
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
{new Date(entry.time).toLocaleString()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail */}
|
|
{entry.detail && (
|
|
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
|
|
{entry.detail}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderRelatedTickets = () => {
|
|
if (relatedTickets.length === 0) return null;
|
|
|
|
return (
|
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
|
<button
|
|
onClick={() => setShowRelated(!showRelated)}
|
|
className="flex items-center justify-between w-full text-left"
|
|
>
|
|
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
|
|
</h3>
|
|
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
|
</button>
|
|
|
|
{showRelated && (
|
|
<div className="mt-3 space-y-2">
|
|
{relatedTickets.map((rt) => (
|
|
<button
|
|
key={rt.id}
|
|
onClick={() => navigate(`/tickets/${rt.id}`)}
|
|
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
|
|
>
|
|
<div>
|
|
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
|
TK-{rt.id} · {rt.title}
|
|
</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
|
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
|
|
{statusMap[rt.status] || rt.status}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Main render */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
return (
|
|
<div className="space-y-5 max-w-5xl">
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate('/tickets')}
|
|
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
|
|
aria-label="返回工单列表"
|
|
>
|
|
<ChevronRight className="w-4 h-4 rotate-180" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
|
|
{ticket.title}
|
|
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
|
{statusMap[ticket.status] || ticket.status}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
|
|
{priorityInfo.label}优先级
|
|
</span>
|
|
</h1>
|
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
|
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
{renderActionButtons()}
|
|
</div>
|
|
|
|
{/* Info cards */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
{/* Ticket Info */}
|
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
|
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ label: '工单号', value: `TK-${ticket.id}` },
|
|
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
|
{ label: '目标数据库', value: ticket.target_database, icon: Database },
|
|
{ label: '申请人', value: ticket.applicant_name || '-' },
|
|
{ label: '审批人', value: ticket.approver_name || '-' },
|
|
{ label: '优先级', value: priorityInfo.label },
|
|
{
|
|
label: '创建时间',
|
|
value: new Date(ticket.create_time).toLocaleString(),
|
|
},
|
|
ticket.review_time && {
|
|
label: '审批时间',
|
|
value: new Date(ticket.review_time).toLocaleString(),
|
|
},
|
|
ticket.execute_time && {
|
|
label: '执行时间',
|
|
value: new Date(ticket.execute_time).toLocaleString(),
|
|
},
|
|
{ label: '变更原因', value: ticket.reason || '-' },
|
|
]
|
|
.filter(Boolean)
|
|
.map((item, idx) => {
|
|
if (!item) return null;
|
|
const ItemIcon = (item as { icon?: React.ElementType }).icon;
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
|
>
|
|
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
|
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
|
{item.label}
|
|
</span>
|
|
<span className="text-sm font-medium">{item.value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SQL Content (SqlEditor read-only) */}
|
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
|
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
|
</h3>
|
|
<SqlEditor
|
|
value={ticket.sql_content}
|
|
onChange={() => { /* read-only */ }}
|
|
language="sql"
|
|
height="280px"
|
|
readOnly
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
);
|
|
})()}
|
|
|
|
{/* Rollback SQL */}
|
|
{ticket.rollback_sql && (
|
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
|
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
|
|
</h3>
|
|
<SqlEditor
|
|
value={ticket.rollback_sql}
|
|
onChange={() => { /* read-only */ }}
|
|
language="sql"
|
|
height="120px"
|
|
readOnly
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Execute result */}
|
|
{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>
|
|
);
|
|
}
|
|
|
|
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()}
|
|
|
|
{/* Related tickets */}
|
|
{renderRelatedTickets()}
|
|
</div>
|
|
);
|
|
}
|