Files
opsxsql/frontend/src/features/ticket/components/TicketDetailDialog.tsx
T
Logan.Li a4090123ba 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)
2026-10-26 19:06:09 +08:00

479 lines
22 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.
/**
* 工单详情弹窗 — 状态流、信息栅格、SQL、执行结果与审批时间线
* 打开时拉取完整详情;审批/执行完成后经 onActionDone 通知页面刷新
*/
import { useState, useEffect, useCallback } from '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';
import { TicketStatusBadge } from './TicketStatusBadge';
interface TicketDetailDialogProps {
ticket: TicketItem;
onClose: () => void;
canApprove: boolean;
canExecute: boolean;
onActionDone: () => void;
}
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[]>([]);
// 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((action: 'approve' | 'reject') => {
setReviewAction(action);
}, []);
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);
}
}, [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">
{/* 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={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"
>
<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-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>
)}
{/* 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>
</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)}
/>
)}
</>
);
}