Files
opsxsql/frontend/src/pages/TicketsPage.tsx
T
loganandClaude Opus 4.7 439155c92e Initial commit: SQL audit platform v0.1.0
FastAPI backend with auth, user, instance, ticket, query, dashboard APIs.
React + TypeScript frontend with mock data UI (10 pages).
SQLite + async SQLAlchemy, JWT auth, role-based access control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:11:57 +08:00

464 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react';
import {
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, Copy,
ChevronRight, Shield, FileCode, History, CircleDot, AlertTriangle,
Database, Key, Lock, Undo2, Info,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { tickets, statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
import { useAuth, useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import type { Ticket } from '@/types';
export default function TicketsPage() {
const { isDeveloper } = useAuth();
const canCreate = useActionPermission('ticket.create');
const canApprove = useActionPermission('ticket.approve');
const canExecute = useActionPermission('ticket.execute');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dbTypeFilter, setDbTypeFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [showNewTicket, setShowNewTicket] = useState(false);
const filtered = useMemo(() => {
return tickets.filter(t => {
if (statusFilter && t.status !== statusFilter) return false;
if (dbTypeFilter && t.dbType !== dbTypeFilter) return false;
if (priorityFilter && t.priority !== priorityFilter) return false;
if (search && !t.title.includes(search) && !t.id.includes(search) && !t.applicant.includes(search)) return false;
// developer 只能看到自己的工单
if (isDeveloper && t.applicant !== '王五' && t.applicant !== 'developer') return false;
return true;
});
}, [search, statusFilter, dbTypeFilter, priorityFilter, isDeveloper]);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
return counts;
}, []);
if (showNewTicket) {
return <NewTicketForm onCancel={() => setShowNewTicket(false)} />;
}
if (selectedTicket) {
return <TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} canApprove={canApprove} canExecute={canExecute} />;
}
return (
<div className="space-y-5">
<PageHeader
title="数据变更工单"
description="管理 SQL 变更工单,提交审核、查看审批进度"
action={canCreate ? {
label: '新建工单', icon: Plus, onClick: () => setShowNewTicket(true), permission: 'ticket.create'
} : undefined}
/>
{/* 状态快捷筛选 */}
<div className="flex gap-2 flex-wrap">
{[
{ key: 'pending', label: '待审批', count: statusCounts.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
{ key: 'reviewing', label: '审批中', count: statusCounts.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
{ key: 'approved', label: '已通过', count: statusCounts.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
{ key: 'executing', label: '执行中', count: statusCounts.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
{ key: 'success', label: '成功', count: statusCounts.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
{ key: 'failed', label: '失败', count: statusCounts.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
].map(s => (
<button
key={s.key}
onClick={() => setStatusFilter(statusFilter === s.key ? '' : s.key)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${statusFilter === s.key ? s.color : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'}`}
>
{s.label}
<span className={`px-1.5 py-0.5 rounded text-xs ${statusFilter === s.key ? 'bg-white/20' : 'bg-[var(--dg-bg-tertiary)]'}`}>{s.count}</span>
</button>
))}
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search 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 focus:ring-1 focus:ring-blue-400/20" placeholder="搜索工单标题/编号/申请人" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="">全部状态</option>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
<option value="">全部数据库</option>
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="redis">Redis</option>
</select>
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)}>
<option value="">全部优先级</option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
</div>
{/* 工单表格 */}
<Card className="dg-card">
<CardContent className="p-0">
{filtered.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-5 py-3 font-semibold">工单号</th>
<th className="text-left px-5 py-3 font-semibold">标题</th>
<th className="text-left px-5 py-3 font-semibold">数据库</th>
<th className="text-left px-5 py-3 font-semibold">实例</th>
<th className="text-left px-5 py-3 font-semibold">申请人</th>
<th className="text-left px-5 py-3 font-semibold">优先级</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
<th className="text-left px-5 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{filtered.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-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3.5"><span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span></td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.instance}</td>
<td className="px-5 py-3.5">{ticket.applicant}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>
{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}
</span>
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
{statusMap[ticket.status]}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button onClick={() => setSelectedTicket(ticket)} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
{canApprove && ticket.status === 'pending' && <button className="text-green-400 hover:text-green-300 transition-colors"><CheckCircle className="w-4 h-4" /></button>}
{canApprove && ticket.status === 'pending' && <button className="text-red-400 hover:text-red-300 transition-colors"><XCircle className="w-4 h-4" /></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
<div className="flex gap-1">
{['上一页', '1', '2', '3', '下一页'].map((p, i) => (
<button key={i} className={`px-3 py-1.5 rounded-md text-sm ${i === 1 ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>{p}</button>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
function NewTicketForm({ onCancel }: { onCancel: () => void }) {
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
const [sql, setSql] = useState('');
return (
<div className="space-y-5 max-w-5xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-6 h-6" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
<input className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="请输入工单标题,如:更新用户状态字段" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label>
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<option>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
<option>生产 MySQL 主库</option><option>测试 MySQL 从库</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
<div className="dg-card overflow-hidden">
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<button onClick={() => setSqlTab('change')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
<FileCode className="w-3.5 h-3.5" /> 变更 SQL
</button>
<button onClick={() => setSqlTab('rollback')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
<RotateCcw className="w-3.5 h-3.5" /> 回滚 SQL
</button>
</div>
<textarea
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
placeholder={sqlTab === 'change' ? "-- 请输入 SQL 语句,支持多条 SQL,使用分号分隔\n\nUPDATE users SET status = 'active' WHERE id = 1;\nINSERT INTO logs (action, user_id) VALUES ('update', 1);" : "-- 请输入回滚 SQL\n\nUPDATE users SET status = (SELECT status FROM users_backup WHERE id = users.id) WHERE id = 1;"}
value={sql}
onChange={e => setSql(e.target.value)}
/>
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
<span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5" />已输入 {sql.length} 个字符</span>
<span>Ctrl + Enter 快速提交</span>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
<textarea className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 placeholder:text-[var(--dg-text-muted)]" placeholder="请描述本次数据变更的原因和背景,便于审批人理解" />
</div>
{/* SQL 审核预览 */}
<div className="dg-card overflow-hidden">
<div className="flex items-center justify-between p-4 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2"><Shield className="w-4 h-4 text-blue-400" /><span className="text-sm font-semibold">SQL 审核预览</span></div>
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/15 rounded-full">
<CheckCircle className="w-4 h-4 text-green-400" /><span className="text-sm font-semibold text-green-400">92 分</span>
</div>
</div>
<div className="divide-y divide-[var(--dg-border-light)]">
{[
{ icon: XCircle, color: 'text-red-400 bg-red-400/10', title: '禁止无 WHERE 条件的 UPDATE/DELETE', desc: '检测到 UPDATE 语句缺少 WHERE 条件', suggestion: '建议: 添加 WHERE 条件限制影响范围' },
{ icon: FileCode, color: 'text-yellow-400 bg-yellow-400/10', title: '大表变更警告', desc: '目标表 users 数据量超过 100 万行', suggestion: '建议: 考虑分批执行或使用 pt-online-schema-change' },
{ icon: CheckCircle, color: 'text-green-400 bg-green-400/10', title: 'SQL 语法检查通过', desc: 'SQL 语法正确,可以正常解析' },
].map((item, i) => (
<div key={i} className="flex items-start gap-3 p-4">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${item.color}`}>
<item.icon className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{item.title}</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{item.desc}</div>
{item.suggestion && <div className="text-xs text-blue-400 mt-1">{item.suggestion}</div>}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
<button className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">提交审核</button>
</div>
</div>
</div>
);
}
function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
ticket: Ticket; onBack: () => void; canApprove: boolean; canExecute: boolean;
}) {
return (
<div className="space-y-5 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] 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]}</span>
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{ticket.id} · 由 {ticket.applicant} 创建于 {ticket.createTime}</p>
</div>
</div>
<div className="flex gap-2">
{canApprove && ticket.status === 'pending' && (
<>
<button 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 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>
</>
)}
{canExecute && ticket.status === 'approved' && (
<button 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>
)}
</div>
</div>
{/* 审批流程 */}
{ticket.workflowSteps && (
<div className="dg-card p-6">
<div className="flex items-center justify-between">
{ticket.workflowSteps.map((step, i) => (
<div key={i} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-2">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium ${step.status === 'completed' ? 'bg-green-500' : step.status === 'active' ? 'bg-blue-500 ring-4 ring-blue-500/20' : 'bg-[var(--dg-bg-tertiary)] border-2 border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}>
{step.status === 'completed' ? <CheckCircle className="w-5 h-5" /> : step.status === 'active' ? <CircleDot className="w-5 h-5" /> : <span className="text-xs">{i + 1}</span>}
</div>
<div className="text-center">
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
<div className="text-[11px] text-[var(--dg-text-muted)]">{step.time || '等待中'}</div>
</div>
</div>
{i < ticket.workflowSteps!.length - 1 && (
<div className={`flex-1 h-[2px] mx-3 mb-6 ${step.status === 'completed' ? 'bg-green-500' : 'bg-[var(--dg-border)]'}`} />
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-5">
{/* 工单信息 */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">工单信息</CardTitle></CardHeader>
<CardContent className="p-5 space-y-3">
{[
{ label: '工单号', value: ticket.id },
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span> },
{ label: '目标实例', value: ticket.instance },
{ label: '申请人', value: ticket.applicant },
{ label: '优先级', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}</span> },
{ label: '创建时间', value: ticket.createTime },
{ label: '变更原因', value: ticket.reason },
].map((item, idx) => (
<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>
))}
</CardContent>
</Card>
{/* SQL 内容 */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><FileCode className="w-4 h-4 text-blue-400" /> SQL 内容</CardTitle></CardHeader>
<CardContent className="p-0">
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.sql}</pre>
</CardContent>
</Card>
</div>
{/* 回滚 SQL */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Undo2 className="w-4 h-4 text-purple-400" /> 回滚 SQL</CardTitle>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Database className="w-3 h-3" /> Binlog ROW</span>
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Key className="w-3 h-3" /> 有主键</span>
<span className="px-2 py-0.5 bg-blue-500/15 text-blue-400 text-xs rounded">支持回滚</span>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{ticket.rollbackSql ? (
<div>
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.rollbackSql}</pre>
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><Info className="w-3 h-3" /> 基于 Binlog ROW 格式解析生成</span>
<span className="flex items-center gap-1"><Lock className="w-3 h-3" /> 备份库: backup_mysql_01</span>
</div>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/15 text-purple-400 text-xs rounded hover:bg-purple-500/25 transition-colors">
<Copy className="w-3 h-3" /> 复制
</button>
</div>
</div>
) : (
<div className="py-8 text-center">
<AlertTriangle className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
<div className="text-sm text-[var(--dg-text-secondary)] mb-1">暂无回滚 SQL</div>
<div className="text-xs text-[var(--dg-text-muted)]">工单执行后将自动生成基于 Binlog 的回滚语句</div>
</div>
)}
</CardContent>
</Card>
{/* 回滚限制说明 */}
<div className="dg-card p-5">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
<h3 className="text-sm font-semibold">回滚功能限制说明</h3>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ icon: Database, title: 'Binlog 配置', status: '已满足', desc: 'MySQL 必须开启 Binlog 且格式为 ROW', color: 'text-green-400 bg-green-400/10' },
{ icon: Key, title: '主键要求', status: '已满足', desc: '操作的表必须包含主键,用于精确定位行', color: 'text-green-400 bg-green-400/10' },
{ icon: Lock, title: '权限要求', status: '已满足', desc: '账号需有 REPLICATION SLAVE/CLIENT 权限', color: 'text-green-400 bg-green-400/10' },
].map((item, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<div className={`w-8 h-8 rounded-lg ${item.color} flex items-center justify-center flex-shrink-0`}>
<item.icon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-medium flex items-center gap-2">{item.title}<span className={`text-[11px] ${item.color.split(' ')[0]}`}>{item.status}</span></div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.desc}</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
<div className="text-xs text-[var(--dg-text-secondary)] flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<span className="font-medium text-yellow-400">重要提示:</span>
DDL操作(DROP/TRUNCATE)的回滚支持有限,仅能重建表结构无法恢复数据;
DML和DDL不可混合执行;WHERE条件可能导致非唯一标识冲突,恢复时务必仔细核对。
参考 Yearning 2.0 实现:AST语法解析 + Binlog逆向操作。
</div>
</div>
</div>
</div>
{/* 审批记录 */}
{ticket.approvalRecords && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><History className="w-4 h-4 text-blue-400" /> 审批记录</CardTitle></CardHeader>
<CardContent className="p-5">
<div className="relative pl-6 space-y-6">
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
{ticket.approvalRecords.map((record, i) => (
<div key={i} className="relative">
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${record.action === 'approve' ? 'bg-green-500' : 'bg-red-500'}`}>
{record.action === 'approve' ? <CheckCircle className="w-3 h-3 text-white" /> : <XCircle className="w-3 h-3 text-white" />}
</div>
<div className="text-sm font-medium">{record.approver} {record.action === 'approve' ? '通过审批' : '拒绝审批'}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{record.time}</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{record.comment}</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}