- 全高布局 h-[calc(100vh-3rem)],去掉 max-w-6xl 限制 - 顶栏紧凑排列: 返回+标题+实例+数据库+定时+浏览+格式化+操作按钮 - 左侧 Schema 浏览面板(280px 可折叠) - SQL 编辑器占满剩余空间 - 底部: 审核结果+变更原因(单行 input 替代 textarea) - 按钮精简: 'SQL 检测' + '提交审批',去掉冗长提示文案
851 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|