后端: - 创建工单时检测是否有审核步骤,无审核步骤直接设为 reviewing - ExecuteTicket 允许 reviewing 状态,自动通过 execute 审批记录再执行 - 验证执行人权限(非执行人返回 403) - 列表 API 返回 has_audit_step/is_executor/is_approver 元信息 - 详情 API 返回 approvals 带 approver_name/actual_approver_name 前端: - 按钮权限精确化:通过/拒绝只对审核人显示,执行只对执行人显示 - 详情弹框加载完整审批数据,展示操作时间线 - 时间线:提交人→每个步骤的指派人+实际操作人+操作时间+备注 - 执行结果文本格式 fallback 显示 - 失败工单显示失败原因
934 lines
41 KiB
TypeScript
934 lines
41 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 { 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<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',
|
||
};
|
||
|
||
/** Design-system badge class per status */
|
||
const dgBadgeStatusMap: Record<string, string> = {
|
||
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<string, string> = {
|
||
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 (
|
||
<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]);
|
||
|
||
// 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 (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="数据变更工单"
|
||
description="管理 SQL 变更工单,提交审核、查看审批进度与执行结果"
|
||
action={canCreate ? {
|
||
label: '新建工单', icon: Plus, onClick: () => navigate('/tickets/create'), permission: 'ticket.create'
|
||
} : undefined}
|
||
/>
|
||
|
||
{/* Status Summary Cards */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{stats === null ? (
|
||
Array.from({ length: 4 }).map((_, i) => (
|
||
<div key={i} className="dg-card-section animate-pulse">
|
||
<div className="dg-card-header">
|
||
<div className="h-8 w-12 rounded bg-[var(--dg-bg-tertiary)]" />
|
||
<div className="h-3 w-16 rounded bg-[var(--dg-bg-tertiary)]" />
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
statusSummaryCards.map(card => (
|
||
<button
|
||
key={card.key}
|
||
onClick={() => { setStatusFilter(statusFilter === card.key ? '' : card.key); setPage(1); }}
|
||
className={`dg-card-section text-left transition-all cursor-pointer hover:ring-1 hover:ring-[var(--dg-border)] ${
|
||
statusFilter === card.key ? 'ring-1 ring-blue-500/30 bg-blue-500/5' : ''
|
||
}`}
|
||
>
|
||
<div className="dg-card-header flex items-center justify-between">
|
||
<span className="text-2xl font-bold">{card.count}</span>
|
||
<span className={`dg-status-dot ${card.dot}`} />
|
||
</div>
|
||
<span className="text-xs text-[var(--dg-text-secondary)] mt-1 block">{card.label}</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="dg-input w-full pl-10 pr-4 py-2.5"
|
||
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 */}
|
||
<div className="dg-card overflow-hidden">
|
||
<div className="p-0">
|
||
{loading && tickets.length === 0 ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="dg-table-header">
|
||
<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="dg-table-header">
|
||
<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="dg-table-row"
|
||
>
|
||
{/* 工单编号 */}
|
||
<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={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>
|
||
{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">
|
||
{ticket.pending_steps?.length > 0 ? (
|
||
<div className="flex flex-col gap-1">
|
||
{ticket.pending_steps.map((ps) => (
|
||
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
|
||
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
|
||
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||
{['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* 状态 */}
|
||
<td className="px-4 py-3.5">
|
||
<span className={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||
{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="dg-btn dg-btn-sm dg-btn-ghost"
|
||
>
|
||
<Eye className="w-3.5 h-3.5" />
|
||
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
|
||
</button>
|
||
|
||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
|
||
fetchTickets(); fetchStats();
|
||
} catch { /* ignore */ }
|
||
}}
|
||
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
|
||
>
|
||
<CheckCircle className="w-3.5 h-3.5" />
|
||
通过
|
||
</button>
|
||
)}
|
||
|
||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
await ticketApi.review(ticket.id, { action: 'reject', comment: '拒绝' });
|
||
fetchTickets(); fetchStats();
|
||
} catch { /* ignore */ }
|
||
}}
|
||
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
|
||
>
|
||
<XCircle className="w-3.5 h-3.5" />
|
||
拒绝
|
||
</button>
|
||
)}
|
||
|
||
{canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
await ticketApi.execute(ticket.id);
|
||
fetchTickets(); fetchStats();
|
||
} catch { /* ignore */ }
|
||
}}
|
||
className="dg-btn dg-btn-sm dg-btn-ghost text-purple-400 hover:text-purple-300"
|
||
>
|
||
<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="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
|
||
>
|
||
<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="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||
>
|
||
上一页
|
||
</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={`dg-btn dg-btn-sm dg-btn-ghost ${
|
||
p === page ? 'bg-blue-500 text-white hover:bg-blue-500' : ''
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage(p => p + 1)}
|
||
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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: initialTicket, onClose, canApprove, canExecute, onActionDone }: {
|
||
ticket: TicketItem;
|
||
onClose: () => void;
|
||
canApprove: boolean;
|
||
canExecute: boolean;
|
||
onActionDone: () => void;
|
||
}) {
|
||
const [loading, setLoading] = 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(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 (
|
||
<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-[85vh] overflow-hidden flex flex-col"
|
||
>
|
||
{/* 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={`dg-badge ${dgBadgeStatusMap[ticket.status] || 'dg-badge-blue'}`}>
|
||
{displayStatusMap[ticket.status] || statusMap[ticket.status] || ticket.status}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{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' || ticket.status === 'reviewing') && (
|
||
<button
|
||
onClick={handleExecute}
|
||
disabled={loading}
|
||
className="dg-btn dg-btn-sm bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-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-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>
|
||
)}
|
||
|
||
{/* 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-purple-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>
|
||
);
|
||
} |