Files
opsxsql/frontend/src/pages/TicketsPage.tsx
T
logan b4c982d565 feat: 工单执行人直接执行(自动通过) + 审批时间线 + 执行结果展示
后端:
- 创建工单时检测是否有审核步骤,无审核步骤直接设为 reviewing
- ExecuteTicket 允许 reviewing 状态,自动通过 execute 审批记录再执行
- 验证执行人权限(非执行人返回 403)
- 列表 API 返回 has_audit_step/is_executor/is_approver 元信息
- 详情 API 返回 approvals 带 approver_name/actual_approver_name

前端:
- 按钮权限精确化:通过/拒绝只对审核人显示,执行只对执行人显示
- 详情弹框加载完整审批数据,展示操作时间线
- 时间线:提交人→每个步骤的指派人+实际操作人+操作时间+备注
- 执行结果文本格式 fallback 显示
- 失败工单显示失败原因
2026-05-31 21:58:52 +08:00

934 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus, Eye, CheckCircle, XCircle, Play,
FileCode, Clock, Filter, X, Loader2, Users,
ArrowUpDown, Ban, ChevronLeft, AlertTriangle,
} from 'lucide-react';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { ticketApi } from '@/api/ticket';
import { ModalOverlay } from '@/components/ModalOverlay';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface PendingStep {
step_number: number;
step_name: string;
step_type: string;
approver_names: string[];
}
interface TicketItem {
id: number;
ticket_no: string | null;
title: string;
db_type: string;
instance_id: number;
instance_name: string | null;
target_database: string;
sql_content: string;
status: string;
priority: string;
applicant_id: number;
applicant_name: string | null;
approver_id: number | null;
approver_name: string | null;
current_approver_name: string | null;
pending_steps: PendingStep[];
reason: string | null;
audit_result: string | null;
execute_result: string | null;
create_time: string;
review_time: string | null;
execute_time: string | null;
has_audit_step: boolean;
has_execute_step: boolean;
is_executor: boolean;
is_approver: boolean;
approvals?: ApprovalRecord[];
}
interface ApprovalRecord {
id: number;
step_number: number;
step_name: string | null;
step_type: string | null;
approver_id: number | null;
approver_name: string;
actual_approver_id: number | null;
actual_approver_name: string;
action: string | null;
comment: string | null;
completed_at: string | null;
}
interface TicketListResponse {
total: number;
page: number;
page_size: number;
items: TicketItem[];
}
interface TicketStatistics {
total: number;
pending: number;
reviewing: number;
approved: number;
rejected: number;
executing: number;
success: number;
failed: number;
}
interface ExecuteResult {
status: string;
affected_rows: number;
duration_ms: number;
validation_passed: boolean;
warnings: string[];
errors: string[];
raw_output: string;
before_snapshot?: string;
after_snapshot?: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
/** 展示用状态映射 — 按用户要求简化 */
const displayStatusMap: Record<string, string> = {
pending: '审核中',
reviewing: '审核中',
approved: '审核中',
rejected: '已拒绝',
executing: '执行中',
success: '完成',
failed: '失败',
rollback: '已回滚',
};
const displayStatusColorMap: Record<string, string> = {
pending: 'bg-blue-500/15 text-blue-400',
reviewing: 'bg-blue-500/15 text-blue-400',
approved: 'bg-blue-500/15 text-blue-400',
rejected: 'bg-red-500/15 text-red-400',
executing: 'bg-purple-500/15 text-purple-400',
success: 'bg-green-500/15 text-green-400',
failed: 'bg-red-500/15 text-red-400',
rollback: 'bg-orange-500/15 text-orange-400',
};
/** Design-system badge class per status */
const dgBadgeStatusMap: Record<string, string> = {
pending: 'dg-badge-blue',
reviewing: 'dg-badge-blue',
approved: 'dg-badge-blue',
rejected: 'dg-badge-red',
executing: 'dg-badge-orange',
success: 'dg-badge-green',
failed: 'dg-badge-red',
rollback: 'dg-badge-orange',
};
/** Design-system badge class per db type */
const dgBadgeDbMap: Record<string, string> = {
mysql: 'dg-badge-blue',
postgres: 'dg-badge-purple',
postgresql: 'dg-badge-purple',
redis: 'dg-badge-red',
mongo: 'dg-badge-green',
mongodb: 'dg-badge-green',
};
const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusIndex(status: string): number {
return statusFlow.indexOf(status);
}
function parseExecuteResult(raw: string | null): ExecuteResult | null {
if (!raw) return null;
try {
return JSON.parse(raw) as ExecuteResult;
} catch {
return null;
}
}
/* ------------------------------------------------------------------ */
/* Skeleton */
/* ------------------------------------------------------------------ */
function TableRowSkeleton() {
return (
<tr className="border-b border-[var(--dg-border-light)]">
<td className="px-4 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-4 py-3.5"><div className="h-4 w-20 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
</tr>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function TicketsPage() {
const navigate = useNavigate();
const canCreate = useActionPermission('ticket.create');
const canApprove = useActionPermission('ticket.approve');
const canExecute = useActionPermission('ticket.execute');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<TicketStatistics | null>(null);
const [sortAsc, setSortAsc] = useState(false);
// Detail dialog
const [detailTicket, setDetailTicket] = useState<TicketItem | null>(null);
const fetchTickets = useCallback(async () => {
setLoading(true);
try {
const res = await ticketApi.list({
page, page_size: pageSize,
status: statusFilter || undefined,
keyword: search || undefined,
}) as unknown as TicketListResponse;
setTickets(res.items || []);
setTotal(res.total);
} catch {
/* keep existing data on error */
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, search]);
const fetchStats = useCallback(async () => {
try {
const res = await ticketApi.statistics() as unknown as TicketStatistics;
setStats(res);
} catch {
/* ignore */
}
}, []);
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchStats(); }, [fetchStats]);
// executing 状态工单自动轮询(3秒)
const hasExecuting = tickets.some(t => t.status === 'executing');
useEffect(() => {
if (!hasExecuting) return;
const timer = setInterval(() => { fetchTickets(); fetchStats(); }, 3000);
return () => clearInterval(timer);
}, [hasExecuting, fetchTickets, fetchStats]);
const totalPages = Math.ceil(total / pageSize);
const sortedTickets = useMemo(() => {
const result = [...tickets];
result.sort((a, b) => {
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();
});
return result;
}, [tickets, sortAsc]);
/* ---- Status Summary Cards (design system) ---- */
const statusSummaryCards = useMemo(() => [
{ key: 'pending', label: '待审核', count: (stats?.pending || 0) + (stats?.reviewing || 0) + (stats?.approved || 0), dot: 'dg-status-dot-blue' },
{ key: 'executing', label: '执行中', count: stats?.executing || 0, dot: 'dg-status-dot-orange' },
{ key: 'success', label: '已完成', count: stats?.success || 0, dot: 'dg-status-dot-green' },
{ key: 'failed', label: '失败/拒绝', count: (stats?.failed || 0) + (stats?.rejected || 0), dot: 'dg-status-dot-red' },
], [stats]);
/* ---- Open detail dialog ---- */
const openDetail = useCallback(async (ticketId: number) => {
try {
const detail = await ticketApi.get(ticketId) as unknown as TicketItem;
setDetailTicket(detail);
} catch { /* ignore */ }
}, []);
return (
<div className="space-y-5">
<PageHeader
title="数据变更工单"
description="管理 SQL 变更工单,提交审核、查看审批进度与执行结果"
action={canCreate ? {
label: '新建工单', icon: Plus, onClick: () => navigate('/tickets/create'), permission: 'ticket.create'
} : undefined}
/>
{/* Status Summary Cards */}
<div className="grid grid-cols-4 gap-4">
{stats === null ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="dg-card-section animate-pulse">
<div className="dg-card-header">
<div className="h-8 w-12 rounded bg-[var(--dg-bg-tertiary)]" />
<div className="h-3 w-16 rounded bg-[var(--dg-bg-tertiary)]" />
</div>
</div>
))
) : (
statusSummaryCards.map(card => (
<button
key={card.key}
onClick={() => { setStatusFilter(statusFilter === card.key ? '' : card.key); setPage(1); }}
className={`dg-card-section text-left transition-all cursor-pointer hover:ring-1 hover:ring-[var(--dg-border)] ${
statusFilter === card.key ? 'ring-1 ring-blue-500/30 bg-blue-500/5' : ''
}`}
>
<div className="dg-card-header flex items-center justify-between">
<span className="text-2xl font-bold">{card.count}</span>
<span className={`dg-status-dot ${card.dot}`} />
</div>
<span className="text-xs text-[var(--dg-text-secondary)] mt-1 block">{card.label}</span>
</button>
))
)}
</div>
{/* Search + Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-md">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="dg-input w-full pl-10 pr-4 py-2.5"
placeholder="搜索工单标题或SQL内容..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
/>
{search && (
<button onClick={() => { setSearch(''); setPage(1); }} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Ticket Table */}
<div className="dg-card overflow-hidden">
<div className="p-0">
{loading && tickets.length === 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="dg-table-header">
<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">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button onClick={() => 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>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => <TableRowSkeleton key={i} />)}
</tbody>
</table>
</div>
) : tickets.length === 0 ? (
<EmptyState type="search" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="dg-table-header">
<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">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button onClick={() => 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>
</tr>
</thead>
<tbody>
{sortedTickets.map(ticket => (
<tr
key={ticket.id}
className="dg-table-row"
>
{/* 工单编号 */}
<td className="px-4 py-3.5">
<div className="flex flex-col">
<span className="font-mono text-xs text-[var(--dg-text-primary)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
<span className="text-[11px] text-[var(--dg-text-muted)] mt-0.5 max-w-[180px] truncate">{ticket.title}</span>
</div>
</td>
{/* 实例名称 */}
<td className="px-4 py-3.5">
<div className="flex items-center gap-2">
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>
{ticket.db_type.toUpperCase()}
</span>
<span className="text-sm">{ticket.instance_name || '-'}</span>
</div>
</td>
{/* 提交人 */}
<td className="px-4 py-3.5">
<div className="flex items-center gap-1.5">
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-sm">{ticket.applicant_name || '-'}</span>
</div>
</td>
{/* 待处理 */}
<td className="px-4 py-3.5">
{ticket.pending_steps?.length > 0 ? (
<div className="flex flex-col gap-1">
{ticket.pending_steps.map((ps) => (
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
</div>
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">
{['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'}
</span>
)}
</td>
{/* 状态 */}
<td className="px-4 py-3.5">
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
</span>
</td>
{/* 创建时间 */}
<td className="px-4 py-3.5 text-xs text-[var(--dg-text-secondary)]">
{new Date(ticket.create_time).toLocaleString()}
</td>
{/* 操作 */}
<td className="px-4 py-3.5">
<div className="flex gap-2">
<button
onClick={() => openDetail(ticket.id)}
className="dg-btn dg-btn-sm dg-btn-ghost"
>
<Eye className="w-3.5 h-3.5" />
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
</button>
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
>
<CheckCircle className="w-3.5 h-3.5" />
通过
</button>
)}
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
>
<XCircle className="w-3.5 h-3.5" />
拒绝
</button>
)}
{canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.execute(ticket.id);
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="dg-btn dg-btn-sm dg-btn-ghost text-purple-400 hover:text-purple-300"
>
<Play className="w-3.5 h-3.5" />
执行
</button>
)}
{(ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.cancel(ticket.id);
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
>
<Ban className="w-3.5 h-3.5" />
撤销
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-4 border-t border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条</span>
<div className="flex gap-1">
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
>
上一页
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) {
p = i + 1;
} else if (page <= 3) {
p = i + 1;
} else if (page >= totalPages - 2) {
p = totalPages - 4 + i;
} else {
p = page - 2 + i;
}
return (
<button
key={p}
onClick={() => setPage(p)}
className={`dg-btn dg-btn-sm dg-btn-ghost ${
p === page ? 'bg-blue-500 text-white hover:bg-blue-500' : ''
}`}
>
{p}
</button>
);
})}
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
>
下一页
</button>
</div>
</div>
)}
</div>
</div>
{/* Detail Dialog */}
{detailTicket && (
<TicketDetailDialog
ticket={detailTicket}
onClose={() => setDetailTicket(null)}
canApprove={canApprove}
canExecute={canExecute}
onActionDone={() => { setDetailTicket(null); fetchTickets(); fetchStats(); }}
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Ticket Detail Dialog */
/* ------------------------------------------------------------------ */
function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExecute, onActionDone }: {
ticket: TicketItem;
onClose: () => void;
canApprove: boolean;
canExecute: boolean;
onActionDone: () => void;
}) {
const [loading, setLoading] = useState(false);
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
// Load full detail on mount
useEffect(() => {
(async () => {
try {
const detail = await ticketApi.get(ticket.id) as any;
setTicket(prev => ({ ...prev, ...detail }));
setApprovals(detail.approvals || []);
} catch { /* ignore */ }
})();
}, [ticket.id]);
const execResult = parseExecuteResult(ticket.execute_result);
const currentStepIndex = getStatusIndex(ticket.status);
const statusDisplay = [
{ key: 'pending', label: '提交', icon: FileCode },
{ key: 'reviewing', label: '审批', icon: Eye },
{ key: 'approved', label: '通过', icon: CheckCircle },
{ key: 'executing', label: '执行', icon: Loader2 },
{ key: 'success', label: '完成', icon: CheckCircle },
];
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
setLoading(true);
try {
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
onActionDone();
} catch { /* ignore */ } finally { setLoading(false); }
}, [ticket.id, onActionDone]);
const handleExecute = useCallback(async () => {
setLoading(true);
try {
await ticketApi.execute(ticket.id);
onActionDone();
} catch { /* ignore */ } finally { setLoading(false); }
}, [ticket.id, onActionDone]);
return (
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
<div
className="w-full max-h-[85vh] overflow-hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
</span>
</div>
<div className="flex items-center gap-2">
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<>
<button
onClick={() => handleReview('approve')}
disabled={loading}
className="dg-btn dg-btn-sm bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 disabled:opacity-50"
>
<CheckCircle className="w-3.5 h-3.5" /> 通过
</button>
<button
onClick={() => handleReview('reject')}
disabled={loading}
className="dg-btn dg-btn-sm bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" /> 拒绝
</button>
</>
)}
{canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (
<button
onClick={handleExecute}
disabled={loading}
className="dg-btn dg-btn-sm bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 disabled:opacity-50"
>
<Play className="w-3.5 h-3.5" /> 执行
</button>
)}
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Status Flow */}
<div className="flex items-center justify-between">
{statusDisplay.map((step, idx) => {
const StepIcon = step.icon;
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
const isCurrent = idx === currentStepIndex;
const isFailed = ticket.status === 'failed' || ticket.status === 'rejected';
const showFailed = isFailed && idx === currentStepIndex;
return (
<div key={step.key} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-1.5">
<div className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
showFailed ? 'bg-red-500/15 text-red-400' :
isCurrent ? 'bg-blue-500/15 text-blue-400 ring-2 ring-blue-500/30' :
isCompleted ? 'bg-green-500/15 text-green-400' :
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
}`}>
{showFailed ? <XCircle className="w-4 h-4" /> : <StepIcon className="w-4 h-4" />}
</div>
<span className={`text-[10px] font-medium ${
showFailed ? 'text-red-400' :
isCurrent ? 'text-blue-400' :
isCompleted ? 'text-green-400' :
'text-[var(--dg-text-muted)]'
}`}>
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
</span>
</div>
{idx < statusDisplay.length - 1 && (
<div className={`flex-1 h-[2px] mx-2 rounded-full transition-all ${
idx < currentStepIndex && currentStepIndex >= 0 ? 'bg-green-400/50' : 'bg-[var(--dg-border)]'
}`} />
)}
</div>
);
})}
</div>
{/* Info Grid */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{[
{ label: '提交人', value: ticket.applicant_name || '-' },
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
{ label: '实例', value: ticket.instance_name || '-' },
{ label: '目标库', value: ticket.target_database },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ label: '变更原因', value: ticket.reason || '-' },
{ label: '优先级', value: ticket.priority === 'high' ? '高' : ticket.priority === 'low' ? '低' : '中' },
].map((item, idx) => (
<div key={idx} className="flex justify-between py-1.5">
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
<span className="text-xs font-medium">{item.value}</span>
</div>
))}
</div>
{/* SQL Content */}
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<FileCode className="w-4 h-4 text-blue-400" /> SQL 语句
</h3>
<pre className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
{ticket.sql_content}
</pre>
</div>
{/* Execute Result — 结构化展示 */}
{execResult && (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
{/* Summary */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">状态</div>
<div className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
{execResult.status === 'success' ? '执行成功' : '执行失败'}
</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">影响行数</div>
<div className="text-sm font-medium">{execResult.affected_rows}</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">执行耗时</div>
<div className="text-sm font-medium">{execResult.duration_ms}ms</div>
</div>
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">验证</div>
<div className={`text-sm font-medium ${execResult.validation_passed ? 'text-green-400' : 'text-red-400'}`}>
{execResult.validation_passed ? '通过' : '未通过'}
</div>
</div>
</div>
{/* Raw Output */}
{execResult.raw_output && (
<div className="mb-3">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">原始输出</div>
<pre className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[120px] overflow-y-auto">
{execResult.raw_output}
</pre>
</div>
)}
{/* Errors */}
{execResult.errors && execResult.errors.length > 0 && (
<div>
<div className="text-[10px] text-red-400 mb-1">错误信息</div>
{execResult.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 p-2 mb-1 bg-red-500/5 border border-red-500/20 rounded-md text-xs text-red-400">
<AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
{err}
</div>
))}
</div>
)}
{/* Warnings */}
{execResult.warnings && execResult.warnings.length > 0 && (
<div className="mt-3">
<div className="text-[10px] text-amber-400 mb-1">警告信息</div>
{execResult.warnings.map((w, i) => (
<div key={i} className="p-2 mb-1 bg-amber-500/5 border border-amber-500/20 rounded-md text-xs text-amber-400">
{w}
</div>
))}
</div>
)}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
{/* Execute Result — plain text fallback for non-JSON results */}
{!execResult && ticket.execute_result && (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
}`}>
{ticket.execute_result}
</div>
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
{/* Failure reason for failed/rejected tickets without execute_result */}
{(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400" /> 失败原因
</h3>
<div className="p-3 bg-red-500/5 border border-red-500/20 rounded-lg text-xs text-red-400">
{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
</div>
</div>
)}
{/* Timeline — approval history */}
{approvals.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
</h3>
<div className="space-y-0">
{/* Submit step */}
<div className="flex gap-3 pb-3">
<div className="flex flex-col items-center">
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center">
<FileCode className="w-3 h-3" />
</div>
<div className="w-[2px] flex-1 bg-[var(--dg-border)]" />
</div>
<div className="flex-1 pb-3">
<div className="text-xs font-medium text-green-400">提交申请</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{ticket.applicant_name || `用户#${ticket.applicant_id}`} · {new Date(ticket.create_time).toLocaleString()}
</div>
{ticket.reason && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 bg-[var(--dg-bg-tertiary)] rounded px-2 py-1">{ticket.reason}</div>
)}
</div>
</div>
{/* Group approvals by step_number */}
{Object.values(
approvals.reduce((acc, a) => {
const key = `${a.step_number}_${a.step_name || ''}`;
if (!acc[key]) acc[key] = { step: a, users: [] };
acc[key].users.push(a);
return acc;
}, {} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>)
).map(({ step, users }, idx, arr) => {
const isCompleted = users.some(u => u.action);
const actor = users.find(u => u.actual_approver_id);
const StepIcon = step.step_type === 'execute' ? Play : Eye;
const isLast = idx === arr.length - 1;
return (
<div key={step.id} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
isCompleted ? 'bg-green-500/15 text-green-400' :
step.action === 'reject' ? 'bg-red-500/15 text-red-400' :
'bg-blue-500/15 text-blue-400'
}`}>
<StepIcon className="w-3 h-3" />
</div>
{!isLast && <div className="w-[2px] flex-1 bg-[var(--dg-border)]" />}
</div>
<div className={`flex-1 ${!isLast ? 'pb-3' : ''}`}>
<div className={`text-xs font-medium ${
isCompleted ? 'text-green-400' :
step.action === 'reject' ? 'text-red-400' :
'text-blue-400'
}`}>
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
{!isCompleted && <span className="text-[var(--dg-text-muted)] font-normal ml-1">(待处理)</span>}
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
指派人: {users.map(u => u.approver_name).filter(Boolean).join(', ') || '-'}
</div>
{actor && actor.completed_at && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">
{actor.actual_approver_name} · {actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} · {new Date(actor.completed_at).toLocaleString()}
{actor.comment && <span className="ml-1 text-[var(--dg-text-muted)]">({actor.comment})</span>}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</ModalOverlay>
);
}