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 = { pending: '审核中', reviewing: '审核中', approved: '审核中', rejected: '已拒绝', executing: '执行中', success: '完成', failed: '失败', rollback: '已回滚', }; const displayStatusColorMap: Record = { 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 = { 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 = { 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 (
); } /* ------------------------------------------------------------------ */ /* 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([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize] = useState(10); const [loading, setLoading] = useState(false); const [stats, setStats] = useState(null); const [sortAsc, setSortAsc] = useState(false); // Detail dialog const [detailTicket, setDetailTicket] = useState(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 (
navigate('/tickets/create'), permission: 'ticket.create' } : undefined} /> {/* Status Summary Cards */}
{stats === null ? ( Array.from({ length: 4 }).map((_, i) => (
)) ) : ( statusSummaryCards.map(card => ( )) )}
{/* Search + Filters */}
{ setSearch(e.target.value); setPage(1); }} /> {search && ( )}
{/* Ticket Table */}
{loading && tickets.length === 0 ? (
{Array.from({ length: 5 }).map((_, i) => )}
工单编号 实例名称 提交人 待处理 状态 操作
) : tickets.length === 0 ? ( ) : (
{sortedTickets.map(ticket => ( {/* 工单编号 */} {/* 实例名称 */} {/* 提交人 */} {/* 待处理 */} {/* 状态 */} {/* 创建时间 */} {/* 操作 */} ))}
工单编号 实例名称 提交人 待处理 状态 操作
{ticket.ticket_no || `TK-${ticket.id}`} {ticket.title}
{ticket.db_type.toUpperCase()} {ticket.instance_name || '-'}
{ticket.applicant_name || '-'}
{ticket.pending_steps?.length > 0 ? (
{ticket.pending_steps.map((ps) => (
{ps.step_name}: {ps.approver_names.join(', ')}
))}
) : ( {['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'} )}
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status} {new Date(ticket.create_time).toLocaleString()}
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( )} {canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( )} {canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && ( )} {(ticket.status === 'pending' || ticket.status === 'reviewing') && ( )}
)} {/* Pagination */} {totalPages > 1 && (
共 {total} 条
{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 ( ); })}
)}
{/* Detail Dialog */} {detailTicket && ( setDetailTicket(null)} canApprove={canApprove} canExecute={canExecute} onActionDone={() => { setDetailTicket(null); fetchTickets(); fetchStats(); }} /> )}
); } /* ------------------------------------------------------------------ */ /* 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(initialTicket); const [approvals, setApprovals] = useState([]); // 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 (
{/* Header */}
{ticket.ticket_no || `TK-${ticket.id}`} {displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && ( <> )} {canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && ( )}
{/* Scrollable Content */}
{/* Status Flow */}
{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 (
{showFailed ? : }
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
{idx < statusDisplay.length - 1 && (
= 0 ? 'bg-green-400/50' : 'bg-[var(--dg-border)]' }`} /> )}
); })}
{/* Info Grid */}
{[ { 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) => (
{item.label} {item.value}
))}
{/* SQL Content */}

SQL 语句

              {ticket.sql_content}
            
{/* Execute Result — 结构化展示 */} {execResult && (

执行结果

{/* Summary */}
状态
{execResult.status === 'success' ? '执行成功' : '执行失败'}
影响行数
{execResult.affected_rows}
执行耗时
{execResult.duration_ms}ms
验证
{execResult.validation_passed ? '通过' : '未通过'}
{/* Raw Output */} {execResult.raw_output && (
原始输出
                    {execResult.raw_output}
                  
)} {/* Errors */} {execResult.errors && execResult.errors.length > 0 && (
错误信息
{execResult.errors.map((err, i) => (
{err}
))}
)} {/* Warnings */} {execResult.warnings && execResult.warnings.length > 0 && (
警告信息
{execResult.warnings.map((w, i) => (
{w}
))}
)} {ticket.execute_time && (
执行时间: {new Date(ticket.execute_time).toLocaleString()}
)}
)} {/* Execute Result — plain text fallback for non-JSON results */} {!execResult && ticket.execute_result && (

执行结果

{ticket.execute_result}
{ticket.execute_time && (
执行时间: {new Date(ticket.execute_time).toLocaleString()}
)}
)} {/* Failure reason for failed/rejected tickets without execute_result */} {(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (

失败原因

{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
)} {/* Timeline — approval history */} {approvals.length > 0 && (

操作时间线

{/* Submit step */}
提交申请
{ticket.applicant_name || `用户#${ticket.applicant_id}`} · {new Date(ticket.create_time).toLocaleString()}
{ticket.reason && (
{ticket.reason}
)}
{/* 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) ).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 (
{!isLast &&
}
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')} {!isCompleted && (待处理)}
指派人: {users.map(u => u.approver_name).filter(Boolean).join(', ') || '-'}
{actor && actor.completed_at && (
{actor.actual_approver_name} · {actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} · {new Date(actor.completed_at).toLocaleString()} {actor.comment && ({actor.comment})}
)}
); })}
)}
); }