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

+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>
);
}