Files
opsxsql/frontend/src/pages/TicketDetailPage.tsx
T
logan 43d1f3a234 feat: 工单权限过滤+仪表盘优化+LDAP登录+审批流程+待处理工单
后端:
- ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的)
- 新增 /dashboard/my-tickets 接口(按角色返回待处理工单)
- auth.py 本地优先+LDAP fallback 登录认证
- ldap_config.py LDAPS 证书验证修复
- instance_detail.py developer/auditor 权限修复

前端:
- Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单
- 删除 ECharts 趋势图(无真实数据)
- TicketDetailPage 审批流程时间线展示
- TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批)
- types 新增 TicketApproval 等类型定义
2026-05-25 20:47:15 +08:00

911 lines
38 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/constants';
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 [approvalSteps, setApprovalSteps] = useState<any[]>([]);
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]);
const fetchApprovals = useCallback(async () => {
try {
const res = await ticketApi.approvals(ticketId);
setApprovalSteps(Array.isArray(res) ? res : []);
} catch {
setApprovalSteps([]);
}
}, [ticketId]);
// Fetch related tickets from API
const fetchRelated = useCallback(async () => {
try {
const res = await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 }) as { items?: any[] };
const items = (res?.items || [])
.filter((t: any) => t.id !== ticketId)
.slice(0, 5)
.map((t: any) => ({
id: t.id,
title: t.title,
status: t.status,
applicant_name: t.applicant_name || t.created_by_name,
create_time: t.created_at || t.create_time,
}));
setRelatedTickets(items);
} catch {
setRelatedTickets([]);
}
}, [ticketId, ticket?.instance_id]);
useEffect(() => {
if (!isNaN(ticketId)) {
setLoading(true);
fetchTicket();
fetchTimeline();
fetchApprovals();
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();
fetchApprovals();
} 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 renderApprovalFlow = () => {
if (approvalSteps.length === 0) return null;
const actionLabel: Record<string, string> = {
approved: '已通过',
rejected: '已拒绝',
pending: '待处理',
skipped: '已跳过',
};
const actionStyle: Record<string, { color: string; bg: string }> = {
approved: { color: 'text-green-400', bg: 'bg-green-500/15' },
rejected: { color: 'text-red-400', bg: 'bg-red-500/15' },
pending: { color: 'text-[var(--dg-text-muted)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
};
const stepAccent: Record<string, string> = {
'提交': 'text-blue-400',
'审核': 'text-amber-400',
'执行': 'text-purple-400',
};
const getAccent = (name: string) => {
for (const key of Object.keys(stepAccent)) {
if (name.includes(key)) return stepAccent[key];
}
return 'text-[var(--dg-text-secondary)]';
};
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">
<Shield className="w-4 h-4 text-blue-400" /> 审批流程
</h3>
<div className="space-y-2">
{approvalSteps.map((step, idx) => {
const actCfg = actionStyle[step.action] || actionStyle.pending;
const isCurrent = step.action === 'pending';
const isDone = step.action === 'approved' || step.action === 'rejected';
return (
<div
key={step.id || idx}
className={`flex items-start gap-3 p-3 rounded-lg ${
isCurrent
? 'bg-blue-500/5 border border-blue-500/20'
: 'bg-[var(--dg-bg-primary)]'
}`}
>
<div className={`w-6 h-6 rounded-full ${actCfg.bg} flex items-center justify-center shrink-0 mt-0.5`}>
{isDone && step.action === 'approved' ? (
<CheckCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : isDone ? (
<XCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : (
<div className={`w-1.5 h-1.5 rounded-full ${isCurrent ? 'bg-blue-400 animate-pulse' : 'bg-[var(--dg-text-muted)]'}`} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${getAccent(step.step_name)}`}>
{step.step_name}
</span>
{step.approver_name && (
<span className="text-xs text-[var(--dg-text-muted)]">
{step.approver_name}
</span>
)}
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${actCfg.bg} ${actCfg.color} shrink-0`}>
{actionLabel[step.action] || step.action}
</span>
</div>
{step.comment && step.comment !== '提交工单' && (
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{step.comment}</p>
)}
{step.updated_at && isDone && (
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{new Date(step.updated_at).toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
</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>
</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: 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>
);
})()}
{/* Approval Flow */}
{renderApprovalFlow()}
{/* Timeline */}
{renderTimeline()}
{/* Related tickets */}
{renderRelatedTickets()}
</div>
);
}