Files
opsxsql/frontend/src/pages/TicketCreatePage.tsx
T
logan 7109031f71 refactor: 新建工单页面全高布局优化 — 参考查询页面风格
- 全高布局 h-[calc(100vh-3rem)],去掉 max-w-6xl 限制
- 顶栏紧凑排列: 返回+标题+实例+数据库+定时+浏览+格式化+操作按钮
- 左侧 Schema 浏览面板(280px 可折叠)
- SQL 编辑器占满剩余空间
- 底部: 审核结果+变更原因(单行 input 替代 textarea)
- 按钮精简: 'SQL 检测' + '提交审批',去掉冗长提示文案
2026-05-25 00:45:53 +08:00

851 lines
42 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ChevronRight, Check, AlertTriangle, Shield, Database,
FileText, Sparkles, RotateCcw, Loader2, Info,
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
KeyRound, Search, Hash, Clock, BarChart3,
X, Zap, Copy, CheckCheck,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
import { instanceDetailApi, type TableMeta, type TableDetail } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
import SqlEditor, { formatSQL } from '@/components/SqlEditor';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
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[];
}
interface RedisKeyItem {
key: string;
type: string;
ttl: number;
size?: number;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const LEVEL_COLOR: Record<string, string> = {
error: 'text-red-400 bg-red-500/15',
warning: 'text-amber-400 bg-amber-500/15',
info: 'text-blue-400 bg-blue-500/15',
};
const LEVEL_LABEL: Record<string, string> = {
error: '错误',
warning: '警告',
info: '提示',
};
const DB_TYPE_LABELS: Record<string, string> = {
mysql: 'MySQL', redis: 'Redis', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB',
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatTTL(ttl: number): string {
if (ttl < 0) return '永不过期';
if (ttl < 60) return ttl + 's';
if (ttl < 3600) return Math.floor(ttl / 60) + 'm';
if (ttl < 86400) return Math.floor(ttl / 3600) + 'h';
return Math.floor(ttl / 86400) + 'd';
}
/* ================================================================== */
/* MAIN COMPONENT */
/* ================================================================== */
export default function TicketCreatePage() {
const navigate = useNavigate();
// -- Form fields --
const [title, setTitle] = useState('');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [reason, setReason] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [enableSchedule, setEnableSchedule] = useState(false);
// -- Data / Loading --
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [databases, setDatabases] = useState<string[]>([]);
const [loadingInstances, setLoadingInstances] = useState(false);
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
// -- Inline audit result --
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
const [validating, setValidating] = useState(false);
const [validated, setValidated] = useState(false);
// -- Schema Browser State --
const [tables, setTables] = useState<TableMeta[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [tableDetail, setTableDetail] = useState<TableDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [copiedDDL, setCopiedDDL] = useState(false);
// -- Redis Browser State --
const [redisDb, setRedisDb] = useState(0);
const [redisPattern, setRedisPattern] = useState('*');
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
const [loadingRedisKeys, setLoadingRedisKeys] = useState(false);
const [selectedRedisKey, setSelectedRedisKey] = useState<string | null>(null);
const [redisKeyDetail, setRedisKeyDetail] = useState<any>(null);
const [loadingRedisDetail, setLoadingRedisDetail] = useState(false);
// -- Browser Tab --
const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis' | 'collections'>('tables');
/* ---------------------------------------------------------------- */
/* Fetch instances */
/* ---------------------------------------------------------------- */
useEffect(() => {
setLoadingInstances(true);
instanceApi.accessible('write')
.then((res: unknown) => {
const data = (res as { items?: unknown[] })?.items ?? res;
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
})
.catch(() => setInstances([]))
.finally(() => setLoadingInstances(false));
}, []);
const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]);
const dbType = selectedInstance?.db_type || '';
useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); return; }
setDatabase(''); setDatabases([]); setTables([]);
const inst = instances.find(i => i.id === Number(instanceId));
if (!inst) return;
setLoadingDatabases(true);
instanceApi.listDatabases(Number(instanceId))
.then((res: unknown) => {
const data = (res as { databases?: string[] })?.databases || (res as string[]) || [];
const list = Array.isArray(data) ? data as string[] : [];
setDatabases(list);
if (list.length > 0) {
setDatabase(inst.db_type === 'redis' ? 'db0' : String(list[0]));
}
})
.catch(() => setDatabases([]))
.finally(() => setLoadingDatabases(false));
}, [instanceId, instances]);
/* ---------------------------------------------------------------- */
/* Fetch tables when instance+db changes */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!instanceId || !database) { setTables([]); return; }
const inst = instances.find(i => i.id === Number(instanceId));
if (!inst || inst.db_type === 'redis') { setTables([]); return; }
setLoadingTables(true);
instanceDetailApi.listTables(Number(instanceId), database)
.then((data) => setTables(Array.isArray(data) ? data : []))
.catch(() => setTables([]))
.finally(() => setLoadingTables(false));
}, [instanceId, database, instances]);
const handleExpandTable = useCallback(async (tableName: string) => {
if (expandedTable === tableName) { setExpandedTable(null); return; }
setExpandedTable(tableName); setTableDetail(null); setLoadingDetail(true);
try {
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName);
setTableDetail(detail);
} catch { /* ignore */ }
finally { setLoadingDetail(false); }
}, [expandedTable, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Fetch Redis keys */
/* ---------------------------------------------------------------- */
const fetchRedisKeys = useCallback(async () => {
if (!instanceId) return;
setLoadingRedisKeys(true);
try {
const res = await redisApi.scanKeys(Number(instanceId), {
db: redisDb, pattern: redisPattern || '*', count: 100,
});
const keys = (res?.keys || []).map((k: any) => ({
key: typeof k === 'string' ? k : k.key,
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
ttl: k.ttl ?? -1, size: k.size || 0,
}));
setRedisKeys(keys);
} catch { setRedisKeys([]); }
finally { setLoadingRedisKeys(false); }
}, [instanceId, redisDb, redisPattern]);
useEffect(() => {
const inst = instances.find(i => i.id === Number(instanceId));
if (inst?.db_type === 'redis' && browserTab === 'redis') fetchRedisKeys();
}, [instanceId, browserTab, fetchRedisKeys, instances]);
const handleSelectRedisKey = useCallback(async (key: string) => {
setSelectedRedisKey(key); setLoadingRedisDetail(true);
try {
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
setRedisKeyDetail(detail);
} catch { setRedisKeyDetail(null); }
finally { setLoadingRedisDetail(false); }
}, [instanceId, redisDb]);
/* ---------------------------------------------------------------- */
/* Insert / change helpers */
/* ---------------------------------------------------------------- */
const insertToEditor = useCallback((text: string) => {
setSqlContent(prev => {
const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
return prev + sep + text + ' ';
});
}, []);
const handleFormatSQL = useCallback(() => {
if (!sqlContent.trim()) return;
setSqlContent(formatSQL(sqlContent));
}, [sqlContent]);
// SQL 内容变化时清除上次审核结果
const handleSqlChange = useCallback((val: string) => {
setSqlContent(val);
if (auditResult) setAuditResult(null);
if (validated) setValidated(false);
}, [auditResult, validated]);
/* ---------------------------------------------------------------- */
/* Validate: SQL 检测(不提交) */
/* ---------------------------------------------------------------- */
const handleValidate = useCallback(async () => {
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
setError(''); setAuditResult(null); setValidating(true);
try {
const result = await ticketApi.audit({
sql_content: sqlContent, instance_id: Number(instanceId), database,
}) as unknown as AuditResult;
setAuditResult(result);
const hasErrors = result.findings?.some((f) => f.level === 'error');
if (hasErrors) {
setError('SQL 审核未通过,请修改后重新检测');
setValidated(false);
} else {
setValidated(true);
}
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || 'SQL 检测失败');
setValidated(false);
} finally { setValidating(false); }
}, [sqlContent, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Submit: 验证通过后直接提交工单 */
/* ---------------------------------------------------------------- */
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError('请填写工单标题'); return; }
if (!instanceId) { setError('请选择目标实例'); return; }
if (!database) { setError('请选择数据库'); return; }
if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; }
if (!reason.trim()) { setError('请填写变更原因'); return; }
setError(''); setSubmitting(true);
try {
const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({
title: title.trim(),
db_type: inst?.db_type || 'mysql',
instance_id: Number(instanceId),
target_database: database,
sql_content: sqlContent,
reason: reason.trim(),
priority: 'medium',
scheduled_at: enableSchedule && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
});
navigate('/tickets');
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } };
const raw = err?.response?.data?.detail;
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
setError(msg);
} finally { setSubmitting(false); }
}, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]);
/* ---------------------------------------------------------------- */
/* Derived values */
/* ---------------------------------------------------------------- */
const isRedis = selectedInstance?.db_type === 'redis';
const isSQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres';
const isMongo = selectedInstance?.db_type === 'mongo';
const errorCount = auditResult?.findings?.filter(f => f.level === 'error').length || 0;
const warnCount = auditResult?.findings?.filter(f => f.level === 'warning').length || 0;
const infoCount = auditResult?.findings?.filter(f => f.level === 'info').length || 0;
const copyToClipboard = useCallback(async (text: string) => {
try { await navigator.clipboard.writeText(text); setCopiedDDL(true); setTimeout(() => setCopiedDDL(false), 2000); } catch { }
}, []);
/* ================================================================== */
/* BROWSER PANEL — Tables / Indexes / Redis Keys */
/* ================================================================== */
const renderBrowserPanel = () => {
if (!instanceId || !database) return null;
const tabs: { id: 'tables' | 'indexes' | 'redis' | 'collections'; label: string; icon: React.ElementType }[] = [];
if (isSQL) {
tabs.push({ id: 'tables', label: '表结构', icon: Table2 });
tabs.push({ id: 'indexes', label: '索引', icon: BarChart3 });
}
if (isMongo) tabs.push({ id: 'collections', label: '集合', icon: Table2 });
if (isRedis) tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound });
if (tabs.length === 0) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)] overflow-hidden">
<div className="flex items-center border-b border-[var(--dg-border)]">
{tabs.map(tab => (
<button key={tab.id} onClick={() => setBrowserTab(tab.id)}
className={'flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium transition-all border-b-2 ' + (
browserTab === tab.id
? 'text-blue-400 border-blue-400 bg-blue-500/5'
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
)}>
<tab.icon className="w-3.5 h-3.5" /> {tab.label}
</button>
))}
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] px-3">点击插入到 SQL</span>
</div>
<div className="max-h-[320px] overflow-y-auto">
{browserTab === 'tables' && renderTablesTab()}
{browserTab === 'indexes' && renderIndexesTab()}
{browserTab === 'redis' && renderRedisTab()}
{browserTab === 'collections' && renderCollectionsTab()}
</div>
</div>
);
};
/* ---- Tables Tab ---- */
const renderTablesTab = () => {
if (loadingTables) return (
<div className="flex items-center gap-2 p-4 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" /> 加载表列表...
</div>
);
if (tables.length === 0) return (
<div className="p-4 text-xs text-[var(--dg-text-muted)] text-center">无可用表</div>
);
return (
<div className="divide-y divide-[var(--dg-border-light)]">
{tables.map(t => (
<div key={t.name}>
<button onClick={() => handleExpandTable(t.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--dg-bg-hover)] transition-colors group">
{expandedTable === t.name
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />}
<Table2 className="w-3.5 h-3.5 text-blue-400" />
<span className="font-mono text-[var(--dg-text-primary)] hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); insertToEditor(t.name); }}
title="点击插入表名">{t.name}</span>
<span className="text-[10px] text-[var(--dg-text-muted)] ml-1">{t.engine}</span>
{t.rows !== undefined && (
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">{t.rows.toLocaleString()} 行</span>
)}
</button>
{expandedTable === t.name && (
<div className="px-3 pb-3">
{loadingDetail ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" /> 加载字段...
</div>
) : tableDetail ? (
<div className="space-y-2">
<div className="ml-5 space-y-0.5">
{tableDetail.columns.map((col) => (
<button key={col.Field} onClick={() => insertToEditor(col.Field)}
className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded hover:bg-[var(--dg-bg-hover)] transition-colors"
title={col.Type + ' ' + (col.Null === 'NO' ? 'NOT NULL' : 'NULL') + ' ' + (col.Key ? '(' + col.Key + ')' : '')}>
<Columns3 className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">{col.Type}</span>
{col.Key === 'PRI' && <span className="text-[9px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
{col.Key === 'MUL' && <span className="text-[9px] px-1 bg-blue-500/10 text-blue-400 rounded">IDX</span>}
</button>
))}
</div>
{tableDetail.ddl && (
<div className="ml-5 mt-2 p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)]">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[var(--dg-text-muted)]">CREATE TABLE</span>
<button onClick={() => copyToClipboard(tableDetail.ddl)}
className="text-[10px] text-blue-400 hover:text-blue-300 flex items-center gap-1">
{copiedDDL ? <><CheckCheck className="w-3 h-3" /> 已复制</> : <><Copy className="w-3 h-3" /> 复制</>}
</button>
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-x-auto max-h-[80px] overflow-y-auto whitespace-pre-wrap">{tableDetail.ddl}</pre>
</div>
)}
</div>
) : <p className="text-xs text-[var(--dg-text-muted)] ml-5">字段信息不可用</p>}
</div>
)}
</div>
))}
</div>
);
};
/* ---- Indexes Tab ---- */
const renderIndexesTab = () => {
if (!expandedTable || !tableDetail?.indexes) return (
<div className="p-4 text-xs text-[var(--dg-text-muted)] text-center">请先选择一个表查看索引</div>
);
const grouped = tableDetail.indexes.reduce((acc, idx) => {
if (!acc[idx.Key_name]) acc[idx.Key_name] = [];
acc[idx.Key_name].push(idx);
return acc;
}, {} as Record<string, typeof tableDetail.indexes>);
return (
<div className="p-3 space-y-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-[var(--dg-text-muted)]">表:</span>
<span className="text-sm font-mono font-medium">{expandedTable}</span>
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">{Object.keys(grouped).length} 个索引</span>
</div>
{Object.entries(grouped).map(([keyName, idxs]) => (
<div key={keyName} className="p-2.5 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)]">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium font-mono">{keyName}</span>
<span className={'text-[10px] px-1.5 py-0.5 rounded ' + (keyName === 'PRIMARY' ? 'bg-yellow-500/10 text-yellow-400' : 'bg-blue-500/10 text-blue-400')}>
{keyName === 'PRIMARY' ? '主键' : idxs[0]?.Index_type || '索引'}
</span>
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">{idxs[0]?.Cardinality?.toLocaleString()} 基数</span>
</div>
<div className="flex flex-wrap gap-1">
{idxs.map((idx, i) => (
<button key={i} onClick={() => insertToEditor(idx.Column_name)}
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors">
{idx.Column_name}
</button>
))}
</div>
</div>
))}
</div>
);
};
/* ---- Redis Tree ---- */
interface RedisTreeNode {
name: string;
fullKey?: string;
type?: string;
children?: RedisTreeNode[];
}
const buildRedisTree = useCallback((keys: RedisKeyItem[]): RedisTreeNode[] => {
const root: RedisTreeNode[] = [];
for (const k of keys) {
const parts = k.key.split(':');
let nodes = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
let node = nodes.find(n => n.name === part);
if (!node) { node = { name: part, children: [] }; nodes.push(node); }
if (i === parts.length - 1) {
node.fullKey = k.key; node.type = k.type; delete node.children;
} else {
if (!node.children) node.children = [];
nodes = node.children;
}
}
}
const sortNodes = (arr: RedisTreeNode[]) => {
arr.sort((a, b) => { if (!!a.fullKey !== !!b.fullKey) return a.fullKey ? 1 : -1; return a.name.localeCompare(b.name); });
arr.forEach(n => { if (n.children) sortNodes(n.children); });
};
sortNodes(root);
return root;
}, []);
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
const toggleRedisFolder = useCallback((path: string) => {
setRedisExpanded(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; });
}, []);
const redisCmdForKey = useCallback((key: string, type: string): string => {
switch (type) {
case 'hash': return 'HGETALL ' + key;
case 'list': return 'LRANGE ' + key + ' 0 -1';
case 'set': return 'SMEMBERS ' + key;
case 'zset': return 'ZRANGE ' + key + ' 0 -1 WITHSCORES';
default: return 'GET ' + key;
}
}, []);
const keyTypeColor: Record<string, string> = {
string: 'text-emerald-400', hash: 'text-blue-400', list: 'text-purple-400', set: 'text-orange-400', zset: 'text-rose-400',
};
const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => {
const path = parentPath ? parentPath + ':' + node.name : node.name;
const isLeaf = !!node.fullKey;
const isExpanded = redisExpanded.has(path);
const indent = depth * 14;
if (isLeaf) {
const isSelected = selectedRedisKey === node.fullKey;
const keyIconColor = keyTypeColor[node.type || 'string'] || 'text-amber-400';
return (
<div key={path}>
<button
onClick={() => {
const cmd = redisCmdForKey(node.fullKey!, node.type || 'string');
setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + cmd);
handleSelectRedisKey(node.fullKey!);
}}
className={'w-full flex items-center gap-1.5 py-1 text-[11px] font-mono transition-colors group ' + (
isSelected ? 'text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'
)}
style={{ paddingLeft: indent + 6 + 'px' }}>
<KeyRound className={'w-3 h-3 ' + keyIconColor + ' shrink-0'} />
<span className="truncate flex-1 text-left">{node.name}</span>
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] shrink-0">{node.type}</span>
<span className="text-[9px] text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">插入</span>
</button>
{isSelected && (
<div className="px-2 pb-2 ml-4 border-l border-[var(--dg-border-light)]">
{loadingRedisDetail ? (
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]"><Loader2 className="w-2.5 h-2.5 animate-spin" /> 加载...</div>
) : redisKeyDetail ? (
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{redisKeyDetail.type}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(redisKeyDetail.ttl)}</span>
{redisKeyDetail.size && <span className="text-[10px] text-[var(--dg-text-muted)]">{formatBytes(redisKeyDetail.size)}</span>}
</div>
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
{typeof redisKeyDetail.value === 'object' ? JSON.stringify(redisKeyDetail.value, null, 2) : String(redisKeyDetail.value)}
</pre>
</div>
) : null}
</div>
)}
</div>
);
}
const childCount = node.children?.length || 0;
return (
<div key={path}>
<button onClick={() => toggleRedisFolder(path)}
className="w-full flex items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
style={{ paddingLeft: indent + 6 + 'px' }}>
{isExpanded ? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" /> : <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />}
<FolderOpen className="w-3 h-3 text-amber-400/70 shrink-0" />
<span className="truncate">{node.name}</span>
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto mr-1">{childCount}</span>
</button>
{isExpanded && (
<div className="border-l border-[var(--dg-border-light)]" style={{ marginLeft: indent + 12 + 'px' }}>
{node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
</div>
)}
</div>
);
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey, loadingRedisDetail, redisKeyDetail]);
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
const renderRedisTab = () => (
<div className="space-y-2">
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)]">
<select value={redisDb} onChange={(e) => { setRedisDb(Number(e.target.value)); fetchRedisKeys(); }}
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500">
{databases.length > 0
? databases.map(db => <option key={db} value={parseInt(db.replace('db', ''))}>{db}</option>)
: Array.from({ length: 16 }, (_, i) => <option key={i} value={i}>DB{i}</option>)}
</select>
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input value={redisPattern} onChange={(e) => setRedisPattern(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="matcher:*" />
</div>
<button onClick={fetchRedisKeys} disabled={loadingRedisKeys}
className="px-2.5 py-1.5 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50">
{loadingRedisKeys ? <Loader2 className="w-3 h-3 animate-spin" /> : '扫描'}
</button>
</div>
<div className="max-h-[300px] overflow-y-auto">
{redisKeys.length > 0 ? redisTree.map(node => renderRedisTreeNode(node, 0, ''))
: <div className="text-xs text-[var(--dg-text-muted)] text-center py-6">{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}</div>}
</div>
</div>
);
/* ---- Collections Tab (MongoDB) ---- */
const renderCollectionsTab = () => {
if (loadingTables) return <div className="flex items-center gap-2 p-4 text-xs text-[var(--dg-text-muted)]"><Loader2 className="w-3 h-3 animate-spin" /> 加载集合列表...</div>;
if (tables.length === 0) return <div className="text-xs text-[var(--dg-text-muted)] text-center py-4">无集合</div>;
return (
<div className="p-2 space-y-0.5">
{tables.map((t) => (
<button key={t.name}
onClick={() => setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + 'db.' + t.name + '.find()')}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left hover:bg-[var(--dg-bg-hover)] transition-colors group">
<Table2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] truncate">{t.name}</span>
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity">插入</span>
</button>
))}
</div>
);
};
/* ================================================================== */
/* MAIN RENDER — 全高布局,参考 QueriesPage */
/* ================================================================== */
const [showBrowser, setShowBrowser] = useState(true);
return (
<div className="flex h-[calc(100vh-3rem)]">
{/* ====== 左侧 Schema 浏览面板 ====== */}
{showBrowser && instanceId && database && (
<div className="w-[280px] shrink-0 border-r border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
<span className="text-xs font-semibold text-[var(--dg-text-secondary)]">库表浏览</span>
<button onClick={() => setShowBrowser(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{renderBrowserPanel()}
</div>
</div>
)}
{/* ====== 主区域 ====== */}
<div className="flex-1 flex flex-col min-w-0 overflow-y-auto">
{/* -- 顶栏 -- */}
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shrink-0 flex-wrap">
{/* 返回 */}
<button onClick={() => navigate('/tickets')}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
title="返回工单列表">
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
{/* 工单标题 */}
<input value={title} onChange={(e) => setTitle(e.target.value)}
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[180px] max-w-[280px]"
placeholder="工单标题" />
{/* 实例 */}
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[160px] disabled:opacity-50">
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
{instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({DB_TYPE_LABELS[inst.db_type] || inst.db_type})
</option>
))}
</select>
{/* 数据库 */}
{isRedis ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)}
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none w-24">
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
</select>
) : databases.length > 0 ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[140px] disabled:opacity-50">
<option value="">{loadingDatabases ? '加载中...' : '选择数据库'}</option>
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
</select>
) : (
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[140px] disabled:opacity-50"
placeholder={instanceId ? '数据库名' : '选择实例'} />
)}
<div className="flex-1" />
{/* 定时执行 */}
<label className="flex items-center gap-1.5 cursor-pointer select-none text-xs text-[var(--dg-text-muted)]">
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
className="w-3.5 h-3.5 rounded border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-blue-500 focus:ring-blue-500 focus:ring-offset-0" />
<Clock className="w-3.5 h-3.5" />
定时
</label>
{enableSchedule && (
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
step="1" min={new Date().toISOString().slice(0, 16)}
className="px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none" />
)}
{/* 浏览面板开关 */}
{instanceId && database && (
<button onClick={() => setShowBrowser(!showBrowser)}
className={`flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md border transition-all ${
showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}>
<Database className="w-3.5 h-3.5" />
{showBrowser ? '隐藏' : '浏览'}
</button>
)}
{/* 格式化 */}
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-30">
<Sparkles className="w-3 h-3" />
</button>
{/* SQL 检测 */}
<button onClick={handleValidate}
disabled={validating || !sqlContent.trim() || !instanceId || !database}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border rounded-md transition-all disabled:opacity-30 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
{validating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Shield className="w-3.5 h-3.5" />}
SQL 检测
</button>
{/* 提交审批 */}
<button onClick={handleSubmit}
disabled={submitting || !validated}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-gradient-to-r from-blue-500 to-purple-600 rounded-md text-white hover:opacity-90 disabled:opacity-40 transition-all">
{submitting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
提交审批
</button>
</div>
{/* -- 定时执行提示 -- */}
{enableSchedule && scheduledAt && (
<div className="px-4 py-1.5 text-xs font-semibold text-red-500 bg-red-500/5 shrink-0">
定时执行:{new Date(scheduledAt).toLocaleString('zh-CN')}
</div>
)}
{/* -- 错误 -- */}
{error && (
<div className="mx-4 mt-3 flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-3 py-2 rounded-lg border border-red-500/20">
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
</div>
)}
{/* -- SQL 编辑器 -- */}
<div className="flex-1 min-h-0 p-4">
<div className="dg-card overflow-hidden h-full flex flex-col">
<SqlEditor value={sqlContent} onChange={handleSqlChange} language="sql"
height="100%"
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)] shrink-0">
<span>
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
{sqlContent.length} 字符
</span>
<div className="flex items-center gap-2">
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 格式化</span>
</div>
</div>
</div>
</div>
{/* -- 变更原因 + 审核结果(底部面板) -- */}
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shrink-0">
{/* 审核结果 */}
{auditResult && (
<div className="px-4 py-2.5 space-y-2">
<div className="flex items-center gap-3 text-xs">
<span className="font-semibold flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-blue-400" /> 审核结果
</span>
{errorCount > 0 && <span className="text-red-400 font-medium">{errorCount} 错误</span>}
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>}
{errorCount === 0 && warnCount === 0 && infoCount === 0 && (
<span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
)}
</div>
{auditResult.findings.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{auditResult.findings.map((f, idx) => (
<span key={idx} className={'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] ' + (LEVEL_COLOR[f.level] || '')}>
<AlertTriangle className="w-3 h-3" />
{LEVEL_LABEL[f.level] || f.level}: {f.message}
</span>
))}
</div>
)}
</div>
)}
{/* 变更原因 */}
<div className="px-4 py-2 flex items-center gap-3">
<span className="text-xs text-[var(--dg-text-muted)] shrink-0">变更原因 <span className="text-red-400">*</span></span>
<input value={reason} onChange={(e) => setReason(e.target.value)}
className="flex-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none"
placeholder="请描述变更原因、影响范围、回滚方案等" />
</div>
</div>
</div>
</div>
);
}