feat: Phase 2 — frontend-backend integration complete

- All 5 core pages now use real API instead of mock data
  - Dashboard: /api/v1/dashboard/stats
  - Tickets: list/create/detail/approve/execute/statistics
  - Users: list/create/toggle-active/delete
  - Instances: list/create/test-connection/delete
  - Queries: execute with result display + sensitive field masking
- Updated DashboardStats type to match backend schema
- Fixed InstancePage to work with InstanceResponse (no qps/cpu/metrics yet)
- Added create forms for Tickets, Users, Instances
- Added connection test in InstanceDetail
- Updated TDD_PLAN.md: Phase 1 ✅ (44 tests), Phase 2 ✅
- Updated ROADMAP.md: v0.2.0 ✅
- Updated PROJECT_EVALUATION.md: score 5.7 → 6.2
- Frontend build passes with zero TS errors
- 44 backend tests still passing
This commit is contained in:
logan committed 2026-05-22 12:39:48 +08:00
1 parent c0c731acfc
commit 27989274f5
10 files changed
+1074 -840

No files matched your search

+275 -220
View File
@@ -1,18 +1,58 @@
import { useState, useMemo } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, Copy,
ChevronRight, Shield, FileCode, History, CircleDot, AlertTriangle,
Database, Key, Lock, Undo2, Info,
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
ChevronRight, FileCode, History,
Undo2, XCircle as XIcon,
} 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 { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import type { Ticket } from '@/types';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
interface TicketItem {
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 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;
}
export default function TicketsPage() {
const { isDeveloper } = useAuth();
const canCreate = useActionPermission('ticket.create');
const canApprove = useActionPermission('ticket.approve');
const canExecute = useActionPermission('ticket.execute');
@@ -21,35 +61,71 @@ export default function TicketsPage() {
const [statusFilter, setStatusFilter] = useState('');
const [dbTypeFilter, setDbTypeFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
const [showNewTicket, setShowNewTicket] = useState(false);
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 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 fetchTickets = useCallback(async () => {
setLoading(true);
try {
const res = await ticketApi.list({
page, page_size: pageSize,
status: statusFilter || undefined,
db_type: dbTypeFilter || 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, dbTypeFilter, search]);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
return counts;
const fetchStats = useCallback(async () => {
try {
const res = await ticketApi.statistics() as unknown as TicketStatistics;
setStats(res);
} catch {}
}, []);
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchStats(); }, [fetchStats]);
const totalPages = Math.ceil(total / pageSize);
if (showNewTicket) {
return <NewTicketForm onCancel={() => setShowNewTicket(false)} />;
return <NewTicketForm
onCancel={() => setShowNewTicket(false)}
onCreated={() => { setShowNewTicket(false); fetchTickets(); fetchStats(); }}
/>;
}
if (selectedTicket) {
return <TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} canApprove={canApprove} canExecute={canExecute} />;
return <TicketDetail
ticket={selectedTicket}
onBack={() => setSelectedTicket(null)}
canApprove={canApprove}
canExecute={canExecute}
onActionDone={() => { setSelectedTicket(null); fetchTickets(); fetchStats(); }}
/>;
}
const statusCards = [
{ key: 'pending', label: '待审批', count: stats?.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
{ key: 'reviewing', label: '审批中', count: stats?.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
{ key: 'approved', label: '已通过', count: stats?.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
{ key: 'executing', label: '执行中', count: stats?.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
{ key: 'success', label: '成功', count: stats?.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
{ key: 'failed', label: '失败', count: stats?.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
];
return (
<div className="space-y-5">
<PageHeader
@@ -62,17 +138,10 @@ export default function TicketsPage() {
{/* 状态快捷筛选 */}
<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 => (
{statusCards.map(s => (
<button
key={s.key}
onClick={() => setStatusFilter(statusFilter === s.key ? '' : s.key)}
onClick={() => { setStatusFilter(statusFilter === s.key ? '' : s.key); setPage(1); }}
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}
@@ -85,19 +154,19 @@ export default function TicketsPage() {
<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)} />
<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="搜索工单标题/SQL内容" value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
</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)}>
<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); setPage(1); }}>
<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)}>
<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); setPage(1); }}>
<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)}>
<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); setPage(1); }}>
<option value="">全部优先级</option>
<option value="high">高</option>
<option value="medium">中</option>
@@ -108,7 +177,9 @@ export default function TicketsPage() {
{/* 工单表格 */}
<Card className="dg-card">
<CardContent className="p-0">
{filtered.length === 0 ? (
{loading && tickets.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
) : tickets.length === 0 ? (
<EmptyState type="search" />
) : (
<>
@@ -119,7 +190,6 @@ export default function TicketsPage() {
<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>
@@ -128,13 +198,12 @@ export default function TicketsPage() {
</tr>
</thead>
<tbody>
{filtered.map(ticket => (
{tickets.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 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{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 ${dbTypeColorMap[ticket.db_type] || ''}`}>{ticket.db_type.toUpperCase()}</span></td>
<td className="px-5 py-3.5">{ticket.applicant_name || '-'}</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' ? '中' : '低'}
@@ -143,15 +212,18 @@ export default function TicketsPage() {
<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]}
{statusMap[ticket.status] || 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 text-[var(--dg-text-secondary)]">{new Date(ticket.create_time).toLocaleString()}</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>}
<button onClick={async () => {
try {
const detail = await ticketApi.get(ticket.id) as unknown as TicketItem;
setSelectedTicket(detail);
} catch {}
}} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
</div>
</td>
</tr>
@@ -160,11 +232,14 @@ export default function TicketsPage() {
</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>
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条记录</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>
))}
<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">上一页</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const p = i + 1;
return <button key={p} onClick={() => setPage(p)} className={`px-3 py-1.5 rounded-md text-sm ${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">下一页</button>
</div>
</div>
</>
@@ -175,107 +250,161 @@ export default function TicketsPage() {
);
}
function NewTicketForm({ onCancel }: { onCancel: () => void }) {
/* ---------- NewTicketForm ---------- */
function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('');
const [dbType, setDbType] = useState('mysql');
const [instanceId, setInstanceId] = useState('');
const [targetDatabase, setTargetDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [rollbackSql, setRollbackSql] = useState('');
const [reason, setReason] = useState('');
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
const [sql, setSql] = useState('');
const [submitting, setSubmitting] = useState(false);
const [instances, setInstances] = useState<any[]>([]);
const [error, setError] = useState('');
useEffect(() => {
instanceApi.list().then(res => {
const data = (res as any)?.items || res;
setInstances(Array.isArray(data) ? data : []);
}).catch(() => {});
}, []);
const handleSubmit = async () => {
if (!title || !sqlContent || !reason || !instanceId || !targetDatabase) {
setError('请填写所有必填项');
return;
}
setSubmitting(true);
setError('');
try {
await ticketApi.create({
title,
db_type: dbType,
instance_id: Number(instanceId),
target_database: targetDatabase,
sql_content: sqlContent,
rollback_sql: rollbackSql || undefined,
reason,
priority,
});
onCreated();
} catch (e: any) {
setError(e?.response?.data?.detail || '提交失败');
} finally {
setSubmitting(false);
}
};
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>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XIcon 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="请输入工单标题,如:更新用户状态字段" />
<input value={title} onChange={e => setTitle(e.target.value)} 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 className="grid grid-cols-3 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 value={dbType} onChange={e => setDbType(e.target.value)} 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 value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">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 value={instanceId} onChange={e => setInstanceId(e.target.value)} 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 value="">请选择实例</option>
{instances.map((inst: any) => <option key={inst.id} value={inst.id}>{inst.name} ({inst.host}:{inst.port})</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">目标数据库 <span className="text-red-400">*</span></label>
<input value={targetDatabase} onChange={e => setTargetDatabase(e.target.value)} 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" placeholder="数据库名" />
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<div className="flex gap-3">
{(['high', 'medium', 'low'] as const).map(p => (
<button key={p} onClick={() => setPriority(p)} className={`px-4 py-2 rounded-lg text-sm border transition-colors ${priority === p ? (p === 'high' ? 'bg-red-500/15 text-red-400 border-red-500/30' : p === 'medium' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : 'bg-blue-500/15 text-blue-400 border-blue-500/30') : 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)]'}`}>
{p === 'high' ? '高' : p === 'medium' ? '中' : '低'}
</button>
))}
</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)]'}`}>
<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)]'}`}>
<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)]'}`}>
<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)]'}`}>
<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)}
placeholder={sqlTab === 'change' ? "UPDATE users SET status = 'active' WHERE id = 1;" : "-- 请输入回滚 SQL"}
value={sqlTab === 'change' ? sqlContent : rollbackSql}
onChange={e => sqlTab === 'change' ? setSqlContent(e.target.value) : setRollbackSql(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>
<span>已输入 {(sqlTab === 'change' ? sqlContent : rollbackSql).length} 个字符</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="请描述本次数据变更的原因和背景,便于审批人理解" />
<textarea value={reason} onChange={e => setReason(e.target.value)} 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" 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>
{error && <div className="text-sm text-red-400">{error}</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>
<button onClick={handleSubmit} disabled={submitting} 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 disabled:opacity-50 transition-opacity">
{submitting ? '提交中...' : '提交审核'}
</button>
</div>
</div>
</div>
);
}
function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
ticket: Ticket; onBack: () => void; canApprove: boolean; canExecute: boolean;
/* ---------- TicketDetail ---------- */
function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }: {
ticket: TicketItem; onBack: () => void; canApprove: boolean; canExecute: boolean; onActionDone: () => void;
}) {
const [loading, setLoading] = useState(false);
const handleReview = async (action: 'approve' | 'reject') => {
setLoading(true);
try {
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
onActionDone();
} catch {} finally { setLoading(false); }
};
const handleExecute = async () => {
setLoading(true);
try {
await ticketApi.execute(ticket.id);
onActionDone();
} catch {} finally { setLoading(false); }
};
return (
<div className="space-y-5 max-w-5xl">
<div className="flex items-center justify-between">
@@ -286,67 +415,44 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
<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>
<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">{ticket.id} · 由 {ticket.applicant} 创建于 {ticket.createTime}</p>
<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">
{canApprove && ticket.status === 'pending' && (
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
<>
<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">
<button onClick={() => handleReview('approve')} disabled={loading} 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">
<button onClick={() => handleReview('reject')} disabled={loading} 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">
<button onClick={handleExecute} disabled={loading} 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: `TK-${ticket.id}` },
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>{ticket.db_type.toUpperCase()}</span> },
{ label: '目标数据库', value: ticket.target_database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ 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 },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ 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>
@@ -360,104 +466,53 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
<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>
<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_content}</pre>
</CardContent>
</Card>
</div>
{/* 回滚 SQL */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
{ticket.rollback_sql && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<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>
</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.rollback_sql}</pre>
</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 && (
{/* 审批信息 */}
{ticket.audit_result && (
<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="relative pl-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 className="relative">
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${ticket.status === 'rejected' ? 'bg-red-500' : 'bg-green-500'}`}>
{ticket.status === 'rejected' ? <XCircle className="w-3 h-3 text-white" /> : <CheckCircle className="w-3 h-3 text-white" />}
</div>
))}
<div className="text-sm font-medium">{ticket.approver_name || '审批人'} {ticket.status === 'rejected' ? '拒绝' : '通过'}</div>
{ticket.review_time && <div className="text-xs text-[var(--dg-text-muted)]">{new Date(ticket.review_time).toLocaleString()}</div>}
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{ticket.audit_result}</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* 执行结果 */}
{ticket.execute_result && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Play className="w-4 h-4 text-purple-400" /> 执行结果</CardTitle></CardHeader>
<CardContent className="p-5">
<pre className="text-xs font-mono text-[var(--dg-text-primary)] 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>}
</CardContent>
</Card>
)}
</div>
);
}