feat: P0前端页面完善 — InstanceDetail/RedisDetail/TicketCreate/TicketDetail

- InstanceDetailPage: 概览/表管理/SQL查询/用户权限/慢查询/进程列表 6个Tab
- RedisDetailPage: 键管理/监控信息/命令行/性能分析 4个Tab + 新增键弹框
- TicketCreatePage: 工单创建向导(选择实例/填写SQL/审核规则检查)
- TicketDetailPage: 工单详情+审批/拒绝/执行/回滚操作
- App.tsx 路由注册: instances/:id, redis/:id, tickets/create, tickets/:id
- API方法名对齐: listDatabases/listTables/listUsers
- 前端构建零错误, 后端202测试通过
This commit is contained in:
logan committed 2026-05-22 23:10:14 +08:00
1 parent 5cbd0e3441
commit dfdac3ad37
7 files changed
+1415 -8

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
Play, RotateCcw, X, Shield,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { statusMap, statusColorMap } from '@/lib/mock';
import { useAuth } from '@/hooks/useAuth';
interface TicketData {
id: number;
title: string;
db_type: string;
instance_id: number;
target_database: string;
sql_content: string;
rollback_sql: string | null;
status: string;
priority: string;
applicant_id: number;
applicant_name: string | null;
approver_id: number | null;
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 TimelineEntry {
action: string;
label: string;
user: string;
time: string | null;
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" />,
};
export default function TicketDetailPage() {
var params = useParams();
var navigate = useNavigate();
var auth = useAuth();
var 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);
var fetchTicket = async function () {
try {
var data = await ticketApi.get(ticketId) as unknown as TicketData;
setTicket(data);
} catch {
setTicket(null);
} finally {
setLoading(false);
}
};
var fetchTimeline = async function () {
try {
var data = await ticketApi.timeline(ticketId) as any;
setTimeline(data.timeline || []);
} catch {
setTimeline([]);
}
};
useEffect(function () {
fetchTicket();
fetchTimeline();
}, [ticketId]);
var handleAction = async function (action: string) {
setActionLoading(true);
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);
}
fetchTicket();
fetchTimeline();
} catch {
} finally {
setActionLoading(false);
}
};
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>
);
}
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' ? '中' : '低';
return (
<div className="space-y-5 max-w-5xl">
{/* Header */}
<div className="flex items-center justify-between">
<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">
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<div>
<h1 className="text-2xl font-semibold flex items-center gap-3">
{ticket.title}
<span className={'px-2.5 py-1 rounded-full text-xs font-medium ' + (statusColorMap[ticket.status] || '')}>
{statusMap[ticket.status] || ticket.status}
</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>
</div>
{/* Info cards */}
<div className="grid grid-cols-2 gap-5">
<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>
<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">
<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>
</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>
</div>
)}
{/* Rollback SQL */}
{ticket.rollback_sql && (
<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">
<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>
</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">
<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>}
</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>
)}
</div>
);
}