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 = { 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 = { 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(null); const [timeline, setTimeline] = useState([]); const [relatedTickets, setRelatedTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); // -- Action states -- const [actionLoading, setActionLoading] = useState(null); const [comment, setComment] = useState(''); const [showCommentInput, setShowCommentInput] = useState(false); const [pendingAction, setPendingAction] = useState(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 (

加载中...

); } if (error || !ticket) { return (

{error || '工单不存在'}

); } const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium; /* ---------------------------------------------------------------- */ /* Render helpers */ /* ---------------------------------------------------------------- */ const renderActionButtons = () => { if (showRollbackConfirm) { return (

确认回滚操作

回滚操作不可逆,将撤销已执行的 SQL 变更

); } return (
{/* Approve / Reject */} {canApprove && ( <> {!showCommentInput ? ( <> ) : (
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 />
)} )} {/* Execute */} {canExecute && ( )} {/* Rollback */} {canRollback && ( )} {/* Cancel */} {canCancel && ( )}
); }; const renderTimeline = () => { if (timeline.length === 0) return null; return (

操作时间线

{/* Vertical line */}
{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 (
{/* Icon node */}
{/* Label + User */}
{entry.label} {entry.user}
{/* Time */} {entry.time && (
{new Date(entry.time).toLocaleString()}
)} {/* Detail */} {entry.detail && (
{entry.detail}
)}
); })}
); }; const renderRelatedTickets = () => { if (relatedTickets.length === 0) return null; return (
{showRelated && (
{relatedTickets.map((rt) => ( ))}
)}
); }; /* ---------------------------------------------------------------- */ /* Main render */ /* ---------------------------------------------------------------- */ return (
{/* Error */} {error && (
{error}
)} {/* Header */}

{ticket.title} {statusMap[ticket.status] || ticket.status} {priorityInfo.label}优先级

TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}

{/* Action buttons */} {renderActionButtons()}
{/* Info cards */}
{/* Ticket Info */}

工单信息

{[ { 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 (
{ItemIcon && } {item.label} {item.value}
); })}
{/* SQL Content (SqlEditor read-only) */}

SQL 内容

{ /* read-only */ }} language="sql" height="280px" readOnly />
{/* 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 (

审核结果

{ticket.audit_result}

); } 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 (
{/* Summary badges */}
{audit.passed ? ( 审核通过 ) : ( 存在阻断项 )} {errorCount > 0 && {errorCount} 错误} {warningCount > 0 && {warningCount} 警告} {infoCount > 0 && {infoCount} 提示}
{/* Findings table */} {showFullAudit && audit.findings.length > 0 && (
{audit.findings.map((f: any, idx: number) => { const levelConfig: Record = { 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 (
{lc.label} {f.rule && {f.rule}}

{f.message}

); })}
)} {/* Summary text */} {!showFullAudit && audit.summary && (

{audit.summary}

)}
); })()} {/* Rollback SQL */} {ticket.rollback_sql && (

回滚 SQL

{ /* read-only */ }} language="sql" height="120px" readOnly />
)} {/* 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 (

执行结果

                {ticket.execute_result}
              
{ticket.execute_time && (
执行时间: {new Date(ticket.execute_time).toLocaleString()}
)}
); } 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 (

执行结果

{/* Status badge + key metrics */}
{isSuccess ? '执行成功' : '执行失败'} {execResult.affected_rows !== undefined && ( 影响行数: {execResult.affected_rows} )} {execResult.duration_ms !== undefined && ( 耗时: {execResult.duration_ms}ms )} {execResult.validation_passed !== undefined && ( {execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'} )}
{/* Errors */} {execResult.errors && execResult.errors.length > 0 && (
{execResult.errors.map((err: string, idx: number) => (
错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}

{err}

))}
)} {/* Warnings */} {execResult.warnings && execResult.warnings.length > 0 && (
{execResult.warnings.map((w: string, idx: number) => (
警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}

{w}

))}
)} {/* Raw output (if present) */} {execResult.raw_output && (

原始输出

                  {execResult.raw_output}
                
)} {ticket.execute_time && (
执行时间: {new Date(ticket.execute_time).toLocaleString()}
)}
); })()} {/* Timeline */} {renderTimeline()} {/* Related tickets */} {renderRelatedTickets()}
); }