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:
1 parent
5cbd0e3441
commit
dfdac3ad37
7 files changed
+1415
-8
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user