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

+8
View File
@@ -16,6 +16,10 @@ import AutoTaskPage from '@/pages/AutoTaskPage';
import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
import DataDictionaryPage from '@/pages/DataDictionaryPage';
import InstanceDetailPage from '@/pages/InstanceDetailPage';
import RedisDetailPage from '@/pages/RedisDetailPage';
import TicketCreatePage from '@/pages/TicketCreatePage';
import TicketDetailPage from '@/pages/TicketDetailPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() {
@@ -52,8 +56,12 @@ function AppRoutes() {
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="instances" element={<InstancePage />} />
<Route path="instances/:id" element={<InstanceDetailPage />} />
<Route path="redis/:id" element={<RedisDetailPage />} />
<Route path="data-dictionary" element={<DataDictionaryPage />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="tickets/create" element={<TicketCreatePage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="queries" element={<QueriesPage />} />
<Route path="roles" element={<RolesPage />} />
<Route path="users" element={<UsersPage />} />
+31 -8
View File
@@ -1,11 +1,34 @@
import request from './request';
export const ticketApi = {
list: (params?: any) => request.get<any, any>('/tickets', { params }),
get: (id: number) => request.get<any, any>(`/tickets/${id}`),
create: (data: any) => request.post<any, any>('/tickets', data),
review: (id: number, data: { action: 'approve' | 'reject'; comment: string }) =>
request.post<any, any>(`/tickets/${id}/review`, data),
execute: (id: number) => request.post<any, any>(`/tickets/${id}/execute`),
statistics: () => request.get('/tickets/statistics'),
export var ticketApi = {
list: function (params?: any) {
return request.get('/tickets', { params: params });
},
get: function (id: number) {
return request.get('/tickets/' + id);
},
create: function (data: any) {
return request.post('/tickets', data);
},
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
return request.post('/tickets/' + id + '/review', data);
},
execute: function (id: number) {
return request.post('/tickets/' + id + '/execute');
},
statistics: function () {
return request.get('/tickets/statistics');
},
timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline');
},
cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel');
},
rollback: function (id: number) {
return request.post('/tickets/' + id + '/rollback');
},
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
return request.post('/tickets/audit', data);
},
};
+289
View File
@@ -0,0 +1,289 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Database, Table2, Users, Clock, Cpu, Play, RefreshCw } from 'lucide-react';
import { instanceDetailApi } from '@/api/instanceDetail';
import { instanceApi } from '@/api/instance';
import { queryApi } from '@/api/query';
interface InstanceInfo {
id: number; name: string; db_type: string; host: string; port: number;
status: string; version: string | null; env: string;
}
type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'slow' | 'process';
export default function InstanceDetailPage() {
var params = useParams<{ id: string }>();
var navigate = useNavigate();
var instanceId = Number(params.id);
var [instance, setInstance] = useState<InstanceInfo | null>(null);
var [activeTab, setActiveTab] = useState<TabKey>('overview');
var [databases, setDatabases] = useState<string[]>([]);
var [selectedDb, setSelectedDb] = useState('');
var [tables, setTables] = useState<any[]>([]);
var [users, setUsers] = useState<any[]>([]);
var [slowQueries, setSlowQueries] = useState<any[]>([]);
var [processes, setProcesses] = useState<any[]>([]);
var [sqlText, setSqlText] = useState('');
var [queryResult, setQueryResult] = useState<any>(null);
var [loading, setLoading] = useState(false);
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'overview', label: '概览', icon: Database },
{ key: 'tables', label: '表管理', icon: Table2 },
{ key: 'query', label: 'SQL查询', icon: Play },
{ key: 'users', label: '用户权限', icon: Users },
{ key: 'slow', label: '慢查询', icon: Clock },
{ key: 'process', label: '进程列表', icon: Cpu },
];
useEffect(function () {
instanceApi.get(instanceId).then(function (res: any) {
setInstance(res);
}).catch(function () {});
}, [instanceId]);
useEffect(function () {
if (activeTab === 'overview' || activeTab === 'tables') {
instanceDetailApi.listDatabases(instanceId).then(function (res: any) {
var dbs = res.databases || res || [];
setDatabases(Array.isArray(dbs) ? dbs : []);
if (dbs.length > 0 && !selectedDb) setSelectedDb(dbs[0]);
}).catch(function () { setDatabases([]); });
}
if (activeTab === 'users') {
instanceDetailApi.listUsers(instanceId).then(function (res: any) {
setUsers(res.users || res || []);
}).catch(function () { setUsers([]); });
}
if (activeTab === 'slow') {
instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) {
setSlowQueries(res.queries || res || []);
}).catch(function () { setSlowQueries([]); });
}
if (activeTab === 'process') {
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
setProcesses(res.processes || res || []);
}).catch(function () { setProcesses([]); });
}
}, [activeTab, selectedDb, instanceId]);
useEffect(function () {
if (activeTab === 'tables' && selectedDb) {
instanceDetailApi.listTables(instanceId, selectedDb).then(function (res: any) {
setTables(res.tables || res || []);
}).catch(function () { setTables([]); });
}
}, [activeTab, selectedDb, instanceId]);
var handleQuery = function () {
if (!sqlText.trim()) return;
setLoading(true);
queryApi.execute({ instance_id: instanceId, database: selectedDb, sql: sqlText }).then(function (res: any) {
setQueryResult(res);
}).catch(function (e: any) {
setQueryResult({ error: e?.response?.data?.detail || '查询失败' });
}).finally(function () { setLoading(false); });
};
var handleKill = function (pid: number) {
if (!confirm('确定 Kill 进程 ' + pid + '?')) return;
instanceDetailApi.killProcess(instanceId, pid).then(function () {
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
setProcesses(res.processes || res || []);
});
}).catch(function () {});
};
if (!instance) return <div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>;
return (
<div className="space-y-5">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={function () { navigate('/instances'); }} 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)]">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-semibold">{instance.name}</h1>
<p className="text-sm text-[var(--dg-text-muted)]">{instance.host}:{instance.port} · {instance.version || '-'} · {instance.env}</p>
</div>
</div>
<div className={'px-3 py-1 rounded-full text-xs font-medium ' + (instance.status === 'connected' || instance.status === 'online' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')}>
{instance.status === 'connected' || instance.status === 'online' ? '在线' : '离线'}
</div>
</div>
{/* Tab 导航 */}
<div className="flex gap-0 border-b border-[var(--dg-border)]">
{tabs.map(function (tab) {
var Icon = tab.icon;
return (
<button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
className={'flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px '
+ (activeTab === tab.key ? 'border-blue-500 text-blue-400' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
<Icon className="w-4 h-4" />{tab.label}
</button>
);
})}
</div>
{/* 概览 */}
{activeTab === 'overview' && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="dg-card p-4"><div className="text-2xl font-bold text-blue-400">{databases.length}</div><div className="text-xs text-[var(--dg-text-muted)]">数据库</div></div>
<div className="dg-card p-4"><div className="text-2xl font-bold text-green-400">{instance.status === 'connected' ? '在线' : '离线'}</div><div className="text-xs text-[var(--dg-text-muted)]">状态</div></div>
<div className="dg-card p-4"><div className="text-2xl font-bold text-purple-400">{instance.version || '-'}</div><div className="text-xs text-[var(--dg-text-muted)]">版本</div></div>
</div>
<div className="dg-card p-4">
<h3 className="text-sm font-semibold mb-3">数据库列表</h3>
<div className="grid grid-cols-4 gap-2">
{databases.map(function (db) {
return <div key={db} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm cursor-pointer hover:bg-blue-500/10" onClick={function () { setSelectedDb(db); setActiveTab('tables'); }}>{db}</div>;
})}
</div>
</div>
</div>
)}
{/* 表管理 */}
{activeTab === 'tables' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">选择数据库</option>
{databases.map(function (db) { return <option key={db} value={db}>{db}</option>; })}
</select>
<button onClick={function () { if (selectedDb) setActiveTab('tables'); }} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]"><RefreshCw className="w-4 h-4" /></button>
</div>
{tables.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-muted)]">选择数据库查看表列表</div> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">表名</th><th className="px-4 py-2">引擎</th><th className="px-4 py-2">行数</th><th className="px-4 py-2">大小</th><th className="px-4 py-2">注释</th>
</tr></thead>
<tbody>
{tables.map(function (t: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 font-medium">{t.Name || t.name}</td>
<td className="px-4 py-2">{t.Engine || t.engine || '-'}</td>
<td className="px-4 py-2">{t.Rows || t.rows || '-'}</td>
<td className="px-4 py-2">{t.Data_length ? (t.Data_length / 1024 / 1024).toFixed(2) + ' MB' : '-'}</td>
<td className="px-4 py-2 text-[var(--dg-text-muted)]">{t.Comment || t.comment || '-'}</td>
</tr>;
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* SQL查询 */}
{activeTab === 'query' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">选择数据库</option>
{databases.map(function (db) { return <option key={db} value={db}>{db}</option>; })}
</select>
</div>
<textarea value={sqlText} onChange={function (e) { setSqlText(e.target.value); }} className="w-full h-32 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono resize-y" placeholder="输入 SQL 查询语句..." />
<button onClick={handleQuery} disabled={loading} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
{loading ? '执行中...' : '执行查询'}
</button>
{queryResult && queryResult.error && <div className="p-3 bg-red-500/10 text-red-400 rounded-lg text-sm">{queryResult.error}</div>}
{queryResult && !queryResult.error && queryResult.columns && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
{queryResult.columns.map(function (c: string) { return <th key={c} className="px-4 py-2">{c}</th>; })}
</tr></thead>
<tbody>
{(queryResult.rows || []).map(function (row: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
{queryResult.columns.map(function (c: string) { return <td key={c} className="px-4 py-2">{row[c] != null ? String(row[c]) : 'NULL'}</td>; })}
</tr>;
})}
</tbody>
</table>
<div className="text-xs text-[var(--dg-text-muted)] mt-2">共 {queryResult.total || 0} 行 · {queryResult.elapsed_ms || 0}ms</div>
</div>
)}
</div>
)}
{/* 用户权限 */}
{activeTab === 'users' && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">User</th><th className="px-4 py-2">Host</th>
</tr></thead>
<tbody>
{users.map(function (u: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 font-medium">{u.User || u.user}</td>
<td className="px-4 py-2">{u.Host || u.host}</td>
</tr>;
})}
</tbody>
</table>
{users.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无数据</div>}
</div>
)}
{/* 慢查询 */}
{activeTab === 'slow' && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">SQL</th><th className="px-4 py-2">执行时间</th><th className="px-4 py-2">扫描行数</th><th className="px-4 py-2">时间</th>
</tr></thead>
<tbody>
{slowQueries.map(function (q: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 max-w-md truncate font-mono text-xs">{q.sql_text || q.SQL || '-'}</td>
<td className="px-4 py-2">{q.query_time || q.Time || '-'}</td>
<td className="px-4 py-2">{q.rows_examined || q.ScanRows || '-'}</td>
<td className="px-4 py-2">{q.start_time || q.Timestamp || '-'}</td>
</tr>;
})}
</tbody>
</table>
{slowQueries.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无慢查询记录</div>}
</div>
)}
{/* 进程列表 */}
{activeTab === 'process' && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">Id</th><th className="px-4 py-2">User</th><th className="px-4 py-2">Host</th><th className="px-4 py-2">DB</th><th className="px-4 py-2">Time</th><th className="px-4 py-2">State</th><th className="px-4 py-2">操作</th>
</tr></thead>
<tbody>
{processes.map(function (p: any) {
return <tr key={p.Id || p.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2">{p.Id || p.id}</td>
<td className="px-4 py-2">{p.User || p.user}</td>
<td className="px-4 py-2">{p.Host || p.host}</td>
<td className="px-4 py-2">{p.db || p.DB || '-'}</td>
<td className="px-4 py-2">{p.Time || p.time}</td>
<td className="px-4 py-2 text-[var(--dg-text-muted)] max-w-xs truncate">{p.State || p.state || '-'}</td>
<td className="px-4 py-2">
<button onClick={function () { handleKill(p.Id || p.id); }} className="px-2 py-1 text-xs bg-red-500/10 text-red-400 rounded hover:bg-red-500/20">Kill</button>
</td>
</tr>;
})}
</tbody>
</table>
{processes.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无进程</div>}
</div>
)}
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search } from 'lucide-react';
import { redisApi } from '@/api/redis';
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
export default function RedisDetailPage() {
var params = useParams<{ id: string }>();
var navigate = useNavigate();
var instanceId = Number(params.id);
var [info, setInfo] = useState<Record<string, any>>({});
var [activeTab, setActiveTab] = useState<TabKey>('keys');
var [selectedDb, setSelectedDb] = useState(0);
var [keyPattern, setKeyPattern] = useState('*');
var [keyType, setKeyType] = useState('');
var [keys, setKeys] = useState<any[]>([]);
var [selectedKey, setSelectedKey] = useState<any>(null);
var [showAddKey, setShowAddKey] = useState(false);
var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 });
var [cmdInput, setCmdInput] = useState('');
var [cmdOutput, setCmdOutput] = useState<string[]>([]);
var [slowlog, setSlowlog] = useState<any[]>([]);
var [clients, setClients] = useState<any[]>([]);
var [loading, setLoading] = useState(false);
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'keys', label: '键管理', icon: Key },
{ key: 'monitor', label: '监控信息', icon: Activity },
{ key: 'cli', label: '命令行', icon: Terminal },
{ key: 'perf', label: '性能分析', icon: Gauge },
];
useEffect(function () {
redisApi.getInfo(instanceId).then(function (res: any) {
setInfo(res.info || res || {});
}).catch(function () { setInfo({}); });
}, [instanceId]);
var fetchKeys = function () {
setLoading(true);
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
setKeys(res.keys || res || []);
}).catch(function () { setKeys([]); }).finally(function () { setLoading(false); });
};
var handleAddKey = function () {
redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: newKey.value, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () {
setShowAddKey(false);
setNewKey({ key: '', value: '', type: 'string', ttl: -1 });
fetchKeys();
}).catch(function () {});
};
var handleDeleteKey = function (keyName: string) {
if (!confirm('确定删除键 ' + keyName + '?')) return;
redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { fetchKeys(); }).catch(function () {});
};
var handleCommand = function () {
if (!cmdInput.trim()) return;
var cmd = cmdInput;
setCmdInput('');
setCmdOutput(function (prev) { return prev.concat(['> ' + cmd]); });
redisApi.executeCommand(instanceId, { db: selectedDb, command: cmd }).then(function (res: any) {
setCmdOutput(function (prev) { return prev.concat([String(res.result || res.data || JSON.stringify(res))]); });
}).catch(function (e: any) {
setCmdOutput(function (prev) { return prev.concat(['ERROR: ' + (e?.response?.data?.detail || '执行失败')]); });
});
};
useEffect(function () {
if (activeTab === 'perf') {
redisApi.getSlowlog(instanceId, 20).then(function (res: any) { setSlowlog(res.slowlog || res || []); }).catch(function () { setSlowlog([]); });
redisApi.getClients(instanceId).then(function (res: any) { setClients(res.clients || res || []); }).catch(function () { setClients([]); });
}
}, [activeTab, instanceId]);
var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; });
var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; });
return (
<div className="space-y-5">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={function () { navigate('/instances'); }} 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)]">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-semibold">Redis 实例</h1>
<p className="text-sm text-[var(--dg-text-muted)]">#{instanceId}</p>
</div>
</div>
</div>
{/* 信息卡片 */}
{infoScalars.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{infoScalars.slice(0, 4).map(function (e) {
return <div key={e[0]} className="dg-card p-3"><div className="text-lg font-bold">{String(e[1])}</div><div className="text-xs text-[var(--dg-text-muted)]">{e[0]}</div></div>;
})}
</div>
)}
{/* Tab */}
<div className="flex gap-0 border-b border-[var(--dg-border)]">
{tabs.map(function (tab) {
var Icon = tab.icon;
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
className={'flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px '
+ (activeTab === tab.key ? 'border-blue-500 text-blue-400' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
<Icon className="w-4 h-4" />{tab.label}
</button>;
})}
</div>
{/* 键管理 */}
{activeTab === 'keys' && (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
</select>
<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 value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} className="w-full pl-10 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="pattern, 如 user:*" />
</div>
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">全部类型</option>
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
</select>
<button onClick={fetchKeys} disabled={loading} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"><RefreshCw className="w-4 h-4" /></button>
<button onClick={function () { setShowAddKey(true); }} className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm flex items-center gap-1"><Plus className="w-4 h-4" />新增</button>
</div>
{/* 键列表 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">键名</th><th className="px-4 py-2">类型</th><th className="px-4 py-2">TTL</th><th className="px-4 py-2">操作</th>
</tr></thead>
<tbody>
{keys.map(function (k: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 font-mono text-xs">{k.key || k.name || k}</td>
<td className="px-4 py-2">{k.type || '-'}</td>
<td className="px-4 py-2">{k.ttl != null ? k.ttl : '-'}</td>
<td className="px-4 py-2 flex gap-2">
<button onClick={function () {
redisApi.getKeyDetail(instanceId, k.key || k.name || k, selectedDb).then(function (res: any) { setSelectedKey(res); }).catch(function () {});
}} className="text-xs text-blue-400 hover:text-blue-300">查看</button>
<button onClick={function () { handleDeleteKey(k.key || k.name || k); }} className="text-xs text-red-400 hover:text-red-300">删除</button>
</td>
</tr>;
})}
</tbody>
</table>
{keys.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">点击刷新加载键列表</div>}
</div>
</div>
)}
{/* 监控信息 */}
{activeTab === 'monitor' && (
<div className="space-y-4">
{infoSections.map(function (section) {
var entries = Object.entries(section[1] as Record<string, any>);
return (
<div key={section[0]} className="dg-card p-4">
<h3 className="text-sm font-semibold mb-3">{section[0]}</h3>
<div className="grid grid-cols-3 gap-2">
{entries.map(function (e) {
return <div key={e[0]} className="flex justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded text-xs"><span className="text-[var(--dg-text-muted)]">{e[0]}</span><span className="font-mono">{String(e[1])}</span></div>;
})}
</div>
</div>
);
})}
</div>
)}
{/* 命令行 */}
{activeTab === 'cli' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
</select>
<input value={cmdInput} onChange={function (e) { setCmdInput(e.target.value); }}
onKeyDown={function (e) { if (e.key === 'Enter') handleCommand(); }}
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono" placeholder="输入 Redis 命令..." />
<button onClick={handleCommand} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">执行</button>
</div>
<div className="dg-card p-4 min-h-[300px] max-h-[400px] overflow-y-auto font-mono text-xs space-y-1">
{cmdOutput.length === 0 && <div className="text-[var(--dg-text-muted)]">等待输入命令...</div>}
{cmdOutput.map(function (line, i) {
return <div key={i} className={line.startsWith('>') ? 'text-green-400' : line.startsWith('ERROR') ? 'text-red-400' : 'text-[var(--dg-text-secondary)]'}>{line}</div>;
})}
</div>
</div>
)}
{/* 性能分析 */}
{activeTab === 'perf' && (
<div className="space-y-4">
<div className="dg-card p-4">
<h3 className="text-sm font-semibold mb-3">慢日志</h3>
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">命令</th><th className="px-4 py-2">耗时(μs)</th><th className="px-4 py-2">时间</th>
</tr></thead>
<tbody>
{slowlog.map(function (s: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)]">
<td className="px-4 py-2 font-mono text-xs">{Array.isArray(s.command) ? s.command.join(' ') : s.command || '-'}</td>
<td className="px-4 py-2">{s.duration || s.execution_time || '-'}</td>
<td className="px-4 py-2">{s.timestamp || '-'}</td>
</tr>;
})}
</tbody>
</table>
{slowlog.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无慢日志</div>}
</div>
<div className="dg-card p-4">
<h3 className="text-sm font-semibold mb-3">客户端列表</h3>
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">ID</th><th className="px-4 py-2">地址</th><th className="px-4 py-2">DB</th><th className="px-4 py-2">空闲(s)</th>
</tr></thead>
<tbody>
{clients.map(function (c: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)]">
<td className="px-4 py-2">{c.id || c.fd || '-'}</td>
<td className="px-4 py-2">{c.addr || c.host || '-'}</td>
<td className="px-4 py-2">{c.db || '-'}</td>
<td className="px-4 py-2">{c.idle || '-'}</td>
</tr>;
})}
</tbody>
</table>
</div>
</div>
)}
{/* 键详情弹窗 */}
{selectedKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={function () { setSelectedKey(null); }}>
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6" onClick={function (e) { e.stopPropagation(); }}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">键详情</h3>
<button onClick={function () { setSelectedKey(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
</div>
<div className="space-y-3">
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div>
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div>
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">TTL:</span><span>{selectedKey.ttl != null ? selectedKey.ttl + 's' : '-'}</span></div>
<div className="text-sm"><span className="text-[var(--dg-text-muted)]">值:</span>
<pre className="mt-1 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono max-h-60 overflow-y-auto">
{typeof selectedKey.value === 'object' ? JSON.stringify(selectedKey.value, null, 2) : String(selectedKey.value || '-')}
</pre>
</div>
</div>
</div>
</div>
)}
{/* 新增键弹窗 */}
{showAddKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={function () { setShowAddKey(false); }}>
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-md p-6" onClick={function (e) { e.stopPropagation(); }}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">新增键</h3>
<button onClick={function () { setShowAddKey(false); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
</div>
<div className="space-y-3">
<div><label className="text-sm text-[var(--dg-text-muted)]">类型</label><select value={newKey.type} onChange={function (e) { setNewKey(Object.assign({}, newKey, { type: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
</select></div>
<div><label className="text-sm text-[var(--dg-text-muted)]">键名</label><input value={newKey.key} onChange={function (e) { setNewKey(Object.assign({}, newKey, { key: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="key" /></div>
<div><label className="text-sm text-[var(--dg-text-muted)]">值</label><textarea value={newKey.value} onChange={function (e) { setNewKey(Object.assign({}, newKey, { value: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[80px]" placeholder="value" /></div>
<div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div>
</div>
<div className="flex justify-end gap-3 mt-4">
<button onClick={function () { setShowAddKey(false); }} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
<button onClick={handleAddKey} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">添加</button>
</div>
</div>
</div>
)}
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
interface InstanceItem {
id: number;
name: string;
host: string;
port: number;
db_type: string;
}
interface AuditFinding {
rule: string;
level: string;
message: string;
}
interface AuditResult {
score: number;
passed: boolean;
has_warnings: boolean;
findings: AuditFinding[];
}
var levelColor: Record<string, string> = {
error: 'text-red-400 bg-red-500/15',
warning: 'text-yellow-400 bg-yellow-500/15',
info: 'text-blue-400 bg-blue-500/15',
};
var levelLabel: Record<string, string> = {
error: '错误',
warning: '警告',
info: '提示',
};
export default function TicketCreatePage() {
var navigate = useNavigate();
var [step, setStep] = useState(1);
var [title, setTitle] = useState('');
var [priority, setPriority] = useState('medium');
var [instanceId, setInstanceId] = useState('');
var [database, setDatabase] = useState('');
var [sqlContent, setSqlContent] = useState('');
var [reason, setReason] = useState('');
var [instances, setInstances] = useState<InstanceItem[]>([]);
var [auditResult, setAuditResult] = useState<AuditResult | null>(null);
var [auditing, setAuditing] = useState(false);
var [submitting, setSubmitting] = useState(false);
var [error, setError] = useState('');
useEffect(function () {
instanceApi.list().then(function (res) {
var data = (res as any)?.items || res;
setInstances(Array.isArray(data) ? data : []);
}).catch(function () {
setInstances([]);
});
}, []);
var handleAudit = async function () {
if (!sqlContent || !instanceId) {
setError('请填写SQL和选择实例');
return;
}
setAuditing(true);
setError('');
try {
var result = await ticketApi.audit({
sql_content: sqlContent,
instance_id: Number(instanceId),
database: database,
}) as unknown as AuditResult;
setAuditResult(result);
setStep(2);
} catch (e: any) {
setError(e?.response?.data?.detail || '审核失败');
} finally {
setAuditing(false);
}
};
var handleSubmit = async function () {
if (!title || !sqlContent || !instanceId || !database || !reason) {
setError('请填写所有必填项');
return;
}
setSubmitting(true);
setError('');
try {
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
await ticketApi.create({
title: title,
db_type: inst?.db_type || 'mysql',
instance_id: Number(instanceId),
target_database: database,
sql_content: sqlContent,
reason: reason,
priority: priority,
});
navigate('/tickets');
} catch (e: any) {
setError(e?.response?.data?.detail || '提交失败');
} finally {
setSubmitting(false);
}
};
var riskLevel = auditResult
? auditResult.score >= 90 ? 'low'
: auditResult.score >= 60 ? 'medium' : 'high'
: '';
var riskLabel: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险' };
var riskColor: Record<string, string> = {
low: 'text-green-400 bg-green-500/15 border-green-500/30',
medium: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
high: 'text-red-400 bg-red-500/15 border-red-500/30',
};
return (
<div className="space-y-5 max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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>
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
</div>
</div>
{/* Steps indicator */}
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
{[
{ num: 1, label: '填写信息' },
{ num: 2, label: 'SQL审核' },
{ num: 3, label: '确认提交' },
].map(function (s, idx) {
var isActive = step === s.num;
var isDone = step > s.num;
return (
<div key={s.num} className="flex items-center flex-1">
<div className={'flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-colors ' + (isActive ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : isDone ? 'bg-green-500/10 text-green-400' : 'text-[var(--dg-text-muted)]')}>
{isDone ? <Check className="w-4 h-4" /> : <span className="w-5 h-5 rounded-full border text-xs flex items-center justify-center">{s.num}</span>}
<span className="text-sm font-medium">{s.label}</span>
</div>
{idx < 2 && <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />}
</div>
);
})}
</div>
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
{/* Step 1: Fill info */}
{step === 1 && (
<div className="space-y-4 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div>
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
<input value={title} onChange={function (e) { setTitle(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="请输入工单标题" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<select value={priority} onChange={function (e) { setPriority(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">选择实例 <span className="text-red-400">*</span></label>
<select value={instanceId} onChange={function (e) { setInstanceId(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<option value="">请选择实例</option>
{instances.map(function (inst) {
return <option key={inst.id} value={String(inst.id)}>{inst.name} ({inst.host}:{inst.port})</option>;
})}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">数据库 <span className="text-red-400">*</span></label>
<input value={database} onChange={function (e) { setDatabase(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="目标数据库名" />
</div>
<div>
<label className="block text-sm font-medium mb-2">SQL语句 <span className="text-red-400">*</span></label>
<textarea value={sqlContent} onChange={function (e) { setSqlContent(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[180px] resize-y" placeholder="请输入SQL语句" />
</div>
<div>
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
<textarea value={reason} onChange={function (e) { setReason(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y" placeholder="请描述变更原因" />
</div>
<div className="flex justify-end pt-2">
<button onClick={handleAudit} disabled={auditing} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
{auditing ? '审核中...' : '开始审核'}
</button>
</div>
</div>
)}
{/* Step 2: Audit result */}
{step === 2 && auditResult && (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
SQL审核结果
</h3>
<div className={'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ' + riskColor[riskLevel]}>
<span className="text-2xl font-bold">{auditResult.score}</span>
<span>分 · {riskLabel[riskLevel]}</span>
</div>
</div>
{auditResult.findings.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-lg font-medium">审核通过,未发现风险</p>
</div>
) : (
<div className="space-y-2">
{auditResult.findings.map(function (f, idx) {
return (
<div key={idx} className={'flex items-start gap-3 p-3 rounded-lg ' + (levelColor[f.level] || '')}>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<span>{levelLabel[f.level] || f.level}</span>
<span className="opacity-60">·</span>
<span className="font-mono text-xs">{f.rule}</span>
</div>
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-between">
<button onClick={function () { setStep(1); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
上一步
</button>
<button onClick={function () { setStep(3); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
下一步:确认提交
</button>
</div>
</div>
)}
{/* Step 3: Confirm and submit */}
{step === 3 && (
<div className="space-y-4">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
<h3 className="text-lg font-semibold">确认工单信息</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-[var(--dg-text-muted)]">标题</span>
<p className="font-medium mt-1">{title}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">优先级</span>
<p className="font-medium mt-1">{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">实例</span>
<p className="font-medium mt-1">{instances.find(function (i) { return i.id === Number(instanceId); })?.name || instanceId}</p>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">数据库</span>
<p className="font-medium mt-1">{database}</p>
</div>
</div>
<div>
<span className="text-sm text-[var(--dg-text-muted)]">SQL语句</span>
<pre className="mt-1 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{sqlContent}</pre>
</div>
<div>
<span className="text-sm text-[var(--dg-text-muted)]">变更原因</span>
<p className="mt-1 text-sm">{reason}</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={function () { setStep(2); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
上一步
</button>
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
{submitting ? '提交中...' : '确认提交'}
</button>
</div>
</div>
)}
</div>
);
}
+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>
);
}