后端: - 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时中间滚动)
743 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|