feat(ticket): stage steps and approval flow conveniences
- TicketStageSteps: status-driven stepper (opsx-sec pattern, active derived from ticket status incl. rejected/failed/cancelled/rollback terminal states) at top of ticket detail page - QuickReviewDialog: inline approve/reject with optional comment for list rows and detail dialog (reject prompts reason, notes terminal semantics); replaces fixed-comment direct calls and alert() usage - execute now requires impact-summary confirm (instance + database + statement count) in both list and detail dialog - DetailTimeline action keys aligned with backend values (created/ pending_review/approve/reject/executed/execution_failed) - icons and colors were silently falling back before - create page: removed dead scheduled_at field (backend never accepted it), added rollback SQL collection (backend-supported), real priority select, approval-flow preview showing bound workflow steps and approvers once instance selected - empty tickets state now embeds create CTA button - RedisDetailPage split: key detail panel extracted to feature component (996 -> 658 lines after formatter reflow; panel 377 lines)
This commit is contained in:
1 parent
f6d636fa44
commit
a4090123ba
10 files changed
+1604
-903
No files matched your search
@@ -3,11 +3,10 @@
|
||||
* 打开时拉取完整详情;审批/执行完成后经 onActionDone 通知页面刷新
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Eye, CheckCircle, XCircle, Play,
|
||||
FileCode, Clock, X, Loader2, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Eye, CheckCircle, XCircle, Play, FileCode, Clock, X, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { QuickReviewDialog } from './QuickReviewDialog';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { getStatusIndex, parseExecuteResult } from '../utils';
|
||||
import type { ApprovalRecord, TicketItem } from '../types';
|
||||
@@ -21,8 +20,16 @@ interface TicketDetailDialogProps {
|
||||
onActionDone: () => void;
|
||||
}
|
||||
|
||||
export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExecute, onActionDone }: TicketDetailDialogProps) {
|
||||
export function TicketDetailDialog({
|
||||
ticket: initialTicket,
|
||||
onClose,
|
||||
canApprove,
|
||||
canExecute,
|
||||
onActionDone,
|
||||
}: TicketDetailDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reviewAction, setReviewAction] = useState<'approve' | 'reject' | null>(null);
|
||||
const [confirmingExecute, setConfirmingExecute] = useState(false);
|
||||
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
|
||||
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
|
||||
|
||||
@@ -30,10 +37,12 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const detail = await ticketApi.get(ticket.id) as any;
|
||||
setTicket(prev => ({ ...prev, ...detail }));
|
||||
const detail = (await ticketApi.get(ticket.id)) as any;
|
||||
setTicket((prev) => ({ ...prev, ...detail }));
|
||||
setApprovals(detail.approvals || []);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}, [ticket.id]);
|
||||
|
||||
@@ -48,320 +57,422 @@ export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove,
|
||||
{ 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 handleReview = useCallback((action: 'approve' | 'reject') => {
|
||||
setReviewAction(action);
|
||||
}, []);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
const confirmReview = useCallback(
|
||||
async (comment: string) => {
|
||||
if (!reviewAction) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.review(ticket.id, { action: reviewAction, comment });
|
||||
setReviewAction(null);
|
||||
onActionDone();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[reviewAction, ticket.id, onActionDone],
|
||||
);
|
||||
|
||||
// 执行:影响面复述确认(opsx-sec 模式)
|
||||
const handleExecute = useCallback(() => {
|
||||
setConfirmingExecute(true);
|
||||
}, []);
|
||||
|
||||
const confirmExecute = useCallback(async () => {
|
||||
setConfirmingExecute(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.execute(ticket.id);
|
||||
onActionDone();
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
} 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-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||
<>
|
||||
<ModalOverlay
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
width="max-w-3xl"
|
||||
className="border border-[var(--dg-border)] overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md: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>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<div className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md: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>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{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' && (
|
||||
<button
|
||||
onClick={() => handleReview('approve')}
|
||||
onClick={handleExecute}
|
||||
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"
|
||||
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||
<Play 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' && (
|
||||
)}
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={loading}
|
||||
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> 执行
|
||||
<X className="w-5 h-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-4 md:p-6 space-y-5">
|
||||
|
||||
{/* Status Flow */}
|
||||
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||
{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-1 sm: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() },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className="text-xs font-medium text-right break-words">{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-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Summary row */}
|
||||
<div className="grid grid-cols-3 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.total_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">SQL 数量</div>
|
||||
<div className="text-sm font-medium">{execResult.items?.length || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-SQL result table */}
|
||||
{execResult.items && execResult.items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] text-left">
|
||||
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
||||
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
||||
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
||||
<th className="pb-2 font-medium">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{execResult.items.map((item, idx) => (
|
||||
<tr key={`${idx}-${item.sql}`} className="border-t border-[var(--dg-border)]">
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{idx + 1}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">{item.sql}</pre>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
||||
{item.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{item.affected_rows}</td>
|
||||
<td className="py-2 pr-3">{item.duration_ms}ms</td>
|
||||
<td className="py-2">
|
||||
{item.error && (
|
||||
<span className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}>
|
||||
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</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-cyan-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" />
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||
{/* Status Flow */}
|
||||
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||
{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>
|
||||
<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>
|
||||
{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-1 sm: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() },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className="text-xs font-medium text-right break-words">{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-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Summary row */}
|
||||
<div className="grid grid-cols-3 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.total_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">SQL 数量</div>
|
||||
<div className="text-sm font-medium">{execResult.items?.length || 0}</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;
|
||||
{/* Per-SQL result table */}
|
||||
{execResult.items && execResult.items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] text-left">
|
||||
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
||||
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
||||
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
||||
<th className="pb-2 font-medium">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{execResult.items.map((item, idx) => (
|
||||
<tr key={`${idx}-${item.sql}`} className="border-t border-[var(--dg-border)]">
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{idx + 1}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">
|
||||
{item.sql}
|
||||
</pre>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
||||
{item.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{item.affected_rows}</td>
|
||||
<td className="py-2 pr-3">{item.duration_ms}ms</td>
|
||||
<td className="py-2">
|
||||
{item.error && (
|
||||
<span
|
||||
className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}
|
||||
>
|
||||
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</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-cyan-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>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
</ModalOverlay>
|
||||
|
||||
{reviewAction && (
|
||||
<QuickReviewDialog
|
||||
ticket={ticket}
|
||||
action={reviewAction}
|
||||
onConfirm={confirmReview}
|
||||
onClose={() => setReviewAction(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmingExecute && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="确认执行工单"
|
||||
description={`将对实例 ${ticket.instance_name || ticket.db_type} 的库 ${ticket.target_database} 执行 #${ticket.id} 的变更语句(事务运行,失败自动回滚)。确认执行?`}
|
||||
confirmText="确认执行"
|
||||
variant="danger"
|
||||
onConfirm={confirmExecute}
|
||||
onCancel={() => setConfirmingExecute(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user