Files
opsxsql/frontend/src/pages/TicketsPage.tsx
T
logan 08f0f88cf1 feat: 工单列表重设计+权限组实例过滤+通知修复
后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
2026-05-24 22:55:51 +08:00

743 lines
33 KiB
TypeScript

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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
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;
reason: string | null;
audit_result: string | null;
execute_result: string | null;
create_time: string;
review_time: string | null;
execute_time: 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',
};
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]);
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]);
/* ---- Stats Cards ---- */
const statusCards = useMemo(() => [
{ key: '', label: '全部', count: stats?.total || 0, color: 'text-[var(--dg-text-primary)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
{ key: 'pending', label: '待审批', count: (stats?.pending || 0) + (stats?.reviewing || 0) + (stats?.approved || 0), color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ key: 'executing', label: '执行中', count: stats?.executing || 0, color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ key: 'success', label: '完成', count: stats?.success || 0, color: 'text-green-400', bg: 'bg-green-400/10' },
{ key: 'failed', label: '失败', count: stats?.failed || 0, color: 'text-red-400', bg: 'bg-red-400/10' },
], [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 Quick Filters */}
<div className="flex gap-2 flex-wrap">
{stats === null ? (
Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 w-24 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
))
) : (
statusCards.map(card => (
<button
key={card.key}
onClick={() => { setStatusFilter(card.key); setPage(1); }}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
statusFilter === card.key
? `${card.bg} ${card.color} ring-1 ring-current/20`
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{card.label}
<span className={`text-xs px-1.5 py-0.5 rounded-full ${card.key === '' ? 'bg-[var(--dg-bg-hover)]' : 'bg-black/20'}`}>
{card.count}
</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="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none transition-colors"
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 */}
<Card className="dg-card">
<CardContent className="p-0">
{loading && tickets.length === 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<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="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<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="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
{/* 工单编号 */}
<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={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
{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">
<span className="text-sm text-[var(--dg-text-secondary)]">
{ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-')}
</span>
</td>
{/* 状态 */}
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${displayStatusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'executing' || ticket.status === 'reviewing' ? 'animate-pulse' : ''}`} />
{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="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
>
<Eye className="w-3.5 h-3.5" />
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
</button>
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.review(ticket.id, { action: 'approve', comment: '审批通过' });
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors"
>
<CheckCircle className="w-3.5 h-3.5" />
通过
</button>
)}
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button
onClick={async () => {
try {
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="flex items-center gap-1 text-red-400 hover:text-red-300 text-xs font-medium transition-colors"
>
<XCircle className="w-3.5 h-3.5" />
拒绝
</button>
)}
{canExecute && ticket.status === 'approved' && (
<button
onClick={async () => {
try {
await ticketApi.execute(ticket.id);
fetchTickets(); fetchStats();
} catch { /* ignore */ }
}}
className="flex items-center gap-1 text-purple-400 hover:text-purple-300 text-xs font-medium transition-colors"
>
<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="flex items-center gap-1 text-amber-400 hover:text-amber-300 text-xs font-medium transition-colors"
>
<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="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
>
上一页
</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={`px-3 py-1.5 rounded-md text-sm transition-colors ${
p === page ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{p}
</button>
);
})}
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1.5 rounded-md text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
>
下一页
</button>
</div>
</div>
)}
</CardContent>
</Card>
{/* 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, onClose, canApprove, canExecute, onActionDone }: {
ticket: TicketItem;
onClose: () => void;
canApprove: boolean;
canExecute: boolean;
onActionDone: () => void;
}) {
const [loading, setLoading] = useState(false);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60" onClick={onClose}>
<div
className="w-full max-w-3xl max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-2xl overflow-hidden flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* 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={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${displayStatusColorMap[ticket.status] || ''}`}>
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
</span>
</div>
<div className="flex items-center gap-2">
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<>
<button
onClick={() => handleReview('approve')}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 disabled:opacity-50 transition-all"
>
<CheckCircle className="w-3.5 h-3.5" /> 通过
</button>
<button
onClick={() => handleReview('reject')}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-500/15 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/25 disabled:opacity-50 transition-all"
>
<XCircle className="w-3.5 h-3.5" /> 拒绝
</button>
</>
)}
{canExecute && ticket.status === 'approved' && (
<button
onClick={handleExecute}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-purple-500/15 text-purple-400 border border-purple-500/30 rounded-md hover:bg-purple-500/25 disabled:opacity-50 transition-all"
>
<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>
)}
</div>
</div>
</div>
);
}