merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化

后端新增 (Step 1):
- mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5)
- redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5)
- mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能)
- postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7)

后端合并 (Step 2):
- audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则)
- audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射
- audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb)

前端优化 (Step 4) — 16页面全覆盖:
- InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage
- UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard
- InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage
- useAuth.tsx, SqlEditor.tsx

后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
logan committed 2026-05-23 16:46:13 +08:00
1 parent e1ba8b3327
commit 58eb78c987
24 files changed
+16333 -2225

No files matched your search

+562 -163
View File
@@ -1,18 +1,26 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
Play, RotateCcw, X, Shield,
Play, RotateCcw, X, Shield, User, Calendar,
MessageSquare, Loader2, AlertTriangle, Database,
BarChart3, ChevronDown, ChevronUp, Link2,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { statusMap, statusColorMap } from '@/lib/mock';
import { useAuth } from '@/hooks/useAuth';
import SqlEditor from '@/components/SqlEditor';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface TicketData {
id: number;
title: string;
db_type: string;
instance_id: number;
instance_name?: string;
target_database: string;
sql_content: string;
rollback_sql: string | null;
@@ -25,6 +33,8 @@ interface TicketData {
reason: string | null;
audit_result: string | null;
execute_result: string | null;
affected_rows?: number;
execute_duration_ms?: number;
create_time: string;
review_time: string | null;
execute_time: string | null;
@@ -38,252 +48,641 @@ interface TimelineEntry {
detail: string | null;
}
var actionIcon: Record<string, React.ReactNode> = {
CREATE_TICKET: <CheckCircle className="w-4 h-4 text-blue-400" />,
REVIEW_TICKET: <Shield className="w-4 h-4 text-green-400" />,
EXECUTE_TICKET: <Play className="w-4 h-4 text-purple-400" />,
CANCEL_TICKET: <XCircle className="w-4 h-4 text-red-400" />,
ROLLBACK_TICKET: <RotateCcw className="w-4 h-4 text-orange-400" />,
interface RelatedTicket {
id: number;
title: string;
status: string;
applicant_name: string | null;
create_time: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
EXECUTE_TICKET: { icon: Play, color: 'text-purple-400', bg: 'bg-purple-500/15' },
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
};
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
high: { label: '高', color: 'text-red-400 bg-red-500/15' },
medium: { label: '中', color: 'text-amber-400 bg-amber-500/15' },
low: { label: '低', color: 'text-green-400 bg-green-500/15' },
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function TicketDetailPage() {
var params = useParams();
var navigate = useNavigate();
var auth = useAuth();
var ticketId = Number(params.id);
const params = useParams();
const navigate = useNavigate();
const auth = useAuth();
const ticketId = Number(params.id);
var [ticket, setTicket] = useState<TicketData | null>(null);
var [timeline, setTimeline] = useState<TimelineEntry[]>([]);
var [loading, setLoading] = useState(true);
var [actionLoading, setActionLoading] = useState(false);
var [comment, setComment] = useState('');
var [showRejectInput, setShowRejectInput] = useState(false);
// -- Data --
const [ticket, setTicket] = useState<TicketData | null>(null);
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
var fetchTicket = async function () {
// -- Action states --
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [comment, setComment] = useState('');
const [showCommentInput, setShowCommentInput] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
// -- UI expansion --
const [showFullAudit, setShowFullAudit] = useState(false);
const [showRelated, setShowRelated] = useState(true);
/* ---------------------------------------------------------------- */
/* Fetch ticket data */
/* ---------------------------------------------------------------- */
const fetchTicket = useCallback(async () => {
try {
var data = await ticketApi.get(ticketId) as unknown as TicketData;
const data = await ticketApi.get(ticketId) as unknown as TicketData;
setTicket(data);
setError('');
} catch {
setError('工单不存在或加载失败');
setTicket(null);
} finally {
setLoading(false);
}
};
}, [ticketId]);
var fetchTimeline = async function () {
const fetchTimeline = useCallback(async () => {
try {
var data = await ticketApi.timeline(ticketId) as any;
setTimeline(data.timeline || []);
const res = await ticketApi.timeline(ticketId) as { timeline?: TimelineEntry[] };
setTimeline(res?.timeline || []);
} catch {
setTimeline([]);
}
};
useEffect(function () {
fetchTicket();
fetchTimeline();
}, [ticketId]);
var handleAction = async function (action: string) {
setActionLoading(true);
// Mock related tickets fetch
const fetchRelated = useCallback(async () => {
// In real implementation: await ticketApi.related(ticketId)
setRelatedTickets([
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
]);
}, [ticketId]);
useEffect(() => {
if (!isNaN(ticketId)) {
setLoading(true);
fetchTicket();
fetchTimeline();
fetchRelated();
}
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
/* ---------------------------------------------------------------- */
/* Action handler */
/* ---------------------------------------------------------------- */
const handleAction = useCallback(async (action: string) => {
if (!ticket) return;
// Reset comment state
const actionComment = comment.trim() || (action === 'reject' ? '审批拒绝' : action === 'approve' ? '审批通过' : '');
setActionLoading(action);
setError('');
try {
if (action === 'approve') {
await ticketApi.review(ticketId, { action: 'approve', comment: '审批通过' });
} else if (action === 'reject') {
await ticketApi.review(ticketId, { action: 'reject', comment: comment || '拒绝' });
setShowRejectInput(false);
setComment('');
} else if (action === 'execute') {
await ticketApi.execute(ticketId);
} else if (action === 'rollback') {
await ticketApi.rollback(ticketId);
} else if (action === 'cancel') {
await ticketApi.cancel(ticketId);
switch (action) {
case 'approve':
await ticketApi.review(ticketId, { action: 'approve', comment: actionComment });
break;
case 'reject':
await ticketApi.review(ticketId, { action: 'reject', comment: actionComment });
setShowCommentInput(false);
setComment('');
break;
case 'execute':
await ticketApi.execute(ticketId);
break;
case 'rollback':
await ticketApi.rollback(ticketId);
setShowRollbackConfirm(false);
break;
case 'cancel':
await ticketApi.cancel(ticketId);
break;
default:
break;
}
fetchTicket();
fetchTimeline();
} catch {
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || `操作失败: ${action}`);
} finally {
setActionLoading(false);
setActionLoading(null);
setPendingAction(null);
}
};
}, [ticket, ticketId, comment, fetchTicket, fetchTimeline]);
/* ---------------------------------------------------------------- */
/* Permission checks */
/* ---------------------------------------------------------------- */
const { isAdminOrDba, isApplicant, canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
if (!ticket) {
return { isAdminOrDba: false, isApplicant: false, canApprove: false, canExecute: false, canRollback: false, canCancel: false };
}
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
const applicant = ticket.applicant_id === auth.user.id;
return {
isAdminOrDba: adminOrDba,
isApplicant: applicant,
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
canExecute: adminOrDba && ticket.status === 'approved',
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing'),
};
}, [ticket, auth.user.role, auth.user.id]);
/* ---------------------------------------------------------------- */
/* Parse execute result for visualization */
/* ---------------------------------------------------------------- */
const executeStats = useMemo(() => {
if (!ticket?.execute_result) return null;
// Try to extract affected rows and duration from execute result
const result = ticket.execute_result;
const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i);
const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i);
return {
affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined),
durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined),
raw: result,
};
}, [ticket]);
/* ---------------------------------------------------------------- */
/* Loading & Error states */
/* ---------------------------------------------------------------- */
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
}
if (!ticket) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<p>工单不存在</p>
<button onClick={function () { navigate('/tickets'); }} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">返回列表</button>
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-3">
<Loader2 className="w-8 h-8 animate-spin" />
<p className="text-sm">加载中...</p>
</div>
);
}
var isAdminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
var isApplicant = ticket.applicant_id === auth.user.id;
var priorityLabel = ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低';
if (error || !ticket) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)] gap-4">
<AlertTriangle className="w-12 h-12 text-amber-400" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{error || '工单不存在'}</p>
<button
onClick={() => navigate('/tickets')}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors"
>
返回列表
</button>
</div>
);
}
const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium;
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderActionButtons = () => {
if (showRollbackConfirm) {
return (
<div className="flex items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
</div>
<button
onClick={() => handleAction('rollback')}
disabled={!!actionLoading}
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
>
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
</button>
<button
onClick={() => setShowRollbackConfirm(false)}
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
);
}
return (
<div className="flex flex-wrap gap-2">
{/* Approve / Reject */}
{canApprove && (
<>
{!showCommentInput ? (
<>
<button
onClick={() => { setPendingAction('approve'); setShowCommentInput(true); }}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
>
<CheckCircle className="w-4 h-4" /> 审批通过
</button>
<button
onClick={() => { setPendingAction('reject'); setShowCommentInput(true); }}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
>
<XCircle className="w-4 h-4" /> 审批拒绝
</button>
</>
) : (
<div className="flex items-center gap-2 w-full">
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
autoFocus
/>
<button
onClick={() => pendingAction && handleAction(pendingAction)}
disabled={!!actionLoading}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
pendingAction === 'reject'
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
} disabled:opacity-50`}
>
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
</button>
<button
onClick={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
)}
</>
)}
{/* Execute */}
{canExecute && (
<button
onClick={() => handleAction('execute')}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all"
>
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
执行
</button>
)}
{/* Rollback */}
{canRollback && (
<button
onClick={() => setShowRollbackConfirm(true)}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
>
<RotateCcw className="w-4 h-4" /> 回滚
</button>
)}
{/* Cancel */}
{canCancel && (
<button
onClick={() => handleAction('cancel')}
disabled={!!actionLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
>
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
取消工单
</button>
)}
</div>
);
};
const renderTimeline = () => {
if (timeline.length === 0) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
</h3>
<div className="relative pl-8">
{/* Vertical line */}
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
{timeline.map((entry, idx) => {
const config = ACTION_CONFIG[entry.action] || {
icon: CheckCircle,
color: 'text-[var(--dg-text-muted)]',
bg: 'bg-[var(--dg-bg-tertiary)]',
};
const Icon = config.icon;
const isLast = idx === timeline.length - 1;
return (
<div key={idx} className={`relative ${isLast ? '' : 'mb-6'}`}>
{/* Icon node */}
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
</div>
<div className="ml-2">
{/* Label + User */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{entry.label}</span>
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
<User className="w-3 h-3" />
{entry.user}
</span>
</div>
{/* Time */}
{entry.time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(entry.time).toLocaleString()}
</div>
)}
{/* Detail */}
{entry.detail && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
{entry.detail}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
const renderExecuteStats = () => {
if (!executeStats) return null;
return (
<div className="mt-3 grid grid-cols-2 gap-3">
{executeStats.affectedRows !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<BarChart3 className="w-3.5 h-3.5" />
影响行数
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.affectedRows.toLocaleString()}
</div>
</div>
)}
{executeStats.durationMs !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<Clock className="w-3.5 h-3.5" />
执行耗时
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.durationMs >= 1000
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
: `${Math.round(executeStats.durationMs)}ms`}
</div>
</div>
)}
</div>
);
};
const renderRelatedTickets = () => {
if (relatedTickets.length === 0) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowRelated(!showRelated)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
</h3>
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
{showRelated && (
<div className="mt-3 space-y-2">
{relatedTickets.map((rt) => (
<button
key={rt.id}
onClick={() => navigate(`/tickets/${rt.id}`)}
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
TK-{rt.id} · {rt.title}
</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
</div>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
{statusMap[rt.status] || rt.status}
</span>
</button>
))}
</div>
)}
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5 max-w-5xl">
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-center gap-4">
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
<button
onClick={() => navigate('/tickets')}
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all shrink-0"
aria-label="返回工单列表"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<div>
<h1 className="text-2xl font-semibold flex items-center gap-3">
<h1 className="text-2xl font-semibold flex items-center gap-3 flex-wrap">
{ticket.title}
<span className={'px-2.5 py-1 rounded-full text-xs font-medium ' + (statusColorMap[ticket.status] || '')}>
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
{priorityInfo.label}优先级
</span>
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
</p>
</div>
</div>
<div className="flex gap-2">
{/* Approve/Reject for admin/dba when pending */}
{isAdminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<>
<button onClick={function () { handleAction('approve'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
<CheckCircle className="w-4 h-4" /> 通过
</button>
{!showRejectInput ? (
<button onClick={function () { setShowRejectInput(true); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
<XCircle className="w-4 h-4" /> 拒绝
</button>
) : (
<div className="flex items-center gap-2">
<input value={comment} onChange={function (e) { setComment(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-48" placeholder="拒绝原因..." />
<button onClick={function () { handleAction('reject'); }} className="px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400">确认拒绝</button>
<button onClick={function () { setShowRejectInput(false); }} className="px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm">取消</button>
</div>
)}
</>
)}
{/* Execute for admin/dba when approved */}
{isAdminOrDba && ticket.status === 'approved' && (
<button onClick={function () { handleAction('execute'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
<Play className="w-4 h-4" /> 执行
</button>
)}
{/* Rollback for admin when completed/success */}
{isAdminOrDba && (ticket.status === 'success' || ticket.status === 'failed') && (
<button onClick={function () { handleAction('rollback'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all">
<RotateCcw className="w-4 h-4" /> 回滚
</button>
)}
{/* Cancel for applicant when pending */}
{isApplicant && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<button onClick={function () { handleAction('cancel'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all">
<X className="w-4 h-4" /> 取消工单
</button>
)}
</div>
{/* Action buttons */}
{renderActionButtons()}
</div>
{/* Info cards */}
<div className="grid grid-cols-2 gap-5">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Ticket Info */}
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
{[
{ label: '工单号', value: 'TK-' + ticket.id },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityLabel },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ label: '变更原因', value: ticket.reason || '-' },
].map(function (item, idx) {
return (
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
<span className="text-sm font-medium">{item.value}</span>
</div>
);
})}
<div className="space-y-1">
{[
{ label: '工单号', value: `TK-${ticket.id}` },
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database, icon: Database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityInfo.label },
{
label: '创建时间',
value: new Date(ticket.create_time).toLocaleString(),
},
ticket.review_time && {
label: '审批时间',
value: new Date(ticket.review_time).toLocaleString(),
},
ticket.execute_time && {
label: '执行时间',
value: new Date(ticket.execute_time).toLocaleString(),
},
{ label: '变更原因', value: ticket.reason || '-' },
]
.filter(Boolean)
.map((item, idx) => {
if (!item) return null;
const ItemIcon = (item as { icon?: React.ElementType }).icon;
return (
<div
key={idx}
className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0"
>
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
{item.label}
</span>
<span className="text-sm font-medium">{item.value}</span>
</div>
);
})}
</div>
</div>
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
{/* SQL Content (SqlEditor read-only) */}
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.sql_content}</pre>
<SqlEditor
value={ticket.sql_content}
onChange={() => { /* read-only */ }}
language="sql"
height="280px"
readOnly
/>
</div>
</div>
{/* Audit result */}
{ticket.audit_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
<div className="mt-3 text-sm">{ticket.audit_result}</div>
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
{ticket.audit_result}
</div>
</div>
)}
{/* Rollback SQL */}
{ticket.rollback_sql && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.rollback_sql}</pre>
<SqlEditor
value={ticket.rollback_sql}
onChange={() => { /* read-only */ }}
language="sql"
height="120px"
readOnly
/>
</div>
)}
{/* Execute result */}
{ticket.execute_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap">{ticket.execute_result}</pre>
{ticket.execute_time && <div className="text-xs text-[var(--dg-text-muted)] mt-2">执行时间: {new Date(ticket.execute_time).toLocaleString()}</div>}
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{renderExecuteStats()}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
{/* Timeline */}
{timeline.length > 0 && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
</h3>
<div className="mt-4 relative pl-8">
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
{timeline.map(function (entry, idx) {
return (
<div key={idx} className="relative mb-6 last:mb-0">
<div className="absolute -left-8 top-0.5 w-5 h-5 rounded-full bg-[var(--dg-bg-secondary)] border-2 border-[var(--dg-border)] flex items-center justify-center z-10">
{actionIcon[entry.action] || <div className="w-2 h-2 rounded-full bg-[var(--dg-text-muted)]" />}
</div>
<div className="ml-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="text-xs text-[var(--dg-text-muted)]">by {entry.user}</span>
</div>
{entry.time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{new Date(entry.time).toLocaleString()}
</div>
)}
{entry.detail && (
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-primary)] rounded-lg">
{entry.detail}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{renderTimeline()}
{/* Related tickets */}
{renderRelatedTickets()}
</div>
);
}