This commit is contained in:
logan committed 2026-05-24 14:08:45 +08:00
1 parent c7fccf99c7
commit 8471f368eb
33 files changed
+444 -2095

No files matched your search

+299 -40
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import {
ChevronRight, Check, AlertTriangle, Shield, Database,
FileText, Sparkles, RotateCcw, Loader2, Info,
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
@@ -39,6 +40,12 @@ interface StepConfig {
icon: React.ElementType;
}
interface TableMeta {
name: string;
rows?: number;
type?: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
@@ -79,6 +86,24 @@ const PRIORITY_OPTIONS = [
{ value: 'high', label: '高' },
];
// 按使用频率排序
const DB_TYPE_OPTIONS = [
{ value: '', label: '请选择数据库类型' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'redis', label: 'Redis' },
{ value: 'postgres', label: 'PostgreSQL' },
{ value: 'mongodb', label: 'MongoDB' },
];
const DB_TYPE_LABELS: Record<string, string> = {
mysql: 'MySQL',
redis: 'Redis',
postgres: 'PostgreSQL',
postgresql: 'PostgreSQL',
mongodb: 'MongoDB',
sqlite: 'SQLite',
};
/* ------------------------------------------------------------------ */
/* Helper: generate rollback SQL suggestion */
/* ------------------------------------------------------------------ */
@@ -86,7 +111,6 @@ const PRIORITY_OPTIONS = [
function generateRollbackSuggestion(sql: string): string {
const upper = sql.toUpperCase().trim();
// UPDATE -> generate reverse UPDATE
if (upper.startsWith('UPDATE')) {
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
@@ -95,19 +119,16 @@ function generateRollbackSuggestion(sql: string): string {
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
}
// DELETE -> generate INSERT from backup
if (upper.startsWith('DELETE')) {
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
}
// ALTER -> generate reverse ALTER
if (upper.startsWith('ALTER')) {
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
}
// INSERT -> generate DELETE
if (upper.startsWith('INSERT')) {
const tableMatch = sql.match(/INTO\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
@@ -130,6 +151,7 @@ export default function TicketCreatePage() {
// -- Form fields --
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('medium');
const [dbType, setDbType] = useState('');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
@@ -148,6 +170,14 @@ export default function TicketCreatePage() {
// -- Audit --
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
// -- Schema browser --
const [showSchema, setShowSchema] = useState(false);
const [tables, setTables] = useState<TableMeta[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [columns, setColumns] = useState<Record<string, string[]>>({});
const [loadingColumns, setLoadingColumns] = useState<string | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch instances on mount */
/* ---------------------------------------------------------------- */
@@ -163,6 +193,39 @@ export default function TicketCreatePage() {
.finally(() => setLoadingInstances(false));
}, []);
/* ---------------------------------------------------------------- */
/* Filtered instances by db type */
/* ---------------------------------------------------------------- */
const filteredInstances = useMemo(() => {
if (!dbType) return instances;
return instances.filter(i => {
if (dbType === 'postgres') return i.db_type === 'postgres' || i.db_type === 'postgresql';
return i.db_type === dbType;
});
}, [instances, dbType]);
/* ---------------------------------------------------------------- */
/* Reset instance when dbType changes */
/* ---------------------------------------------------------------- */
useEffect(() => {
// If current instance doesn't match new dbType, reset it
if (instanceId) {
const current = instances.find(i => i.id === Number(instanceId));
if (current && dbType) {
const match = dbType === 'postgres'
? (current.db_type === 'postgres' || current.db_type === 'postgresql')
: current.db_type === dbType;
if (!match) {
setInstanceId('');
setDatabase('');
setDatabases([]);
}
}
}
}, [dbType, instances, instanceId]);
/* ---------------------------------------------------------------- */
/* Fetch databases when instance changes */
/* ---------------------------------------------------------------- */
@@ -173,6 +236,7 @@ export default function TicketCreatePage() {
setDatabase('');
return;
}
const inst = instances.find(i => i.id === Number(instanceId));
setLoadingDatabases(true);
instanceApi.listDatabases(Number(instanceId))
.then((res: unknown) => {
@@ -182,14 +246,80 @@ export default function TicketCreatePage() {
setDatabases(Array.isArray(data) ? data as string[] : []);
if (Array.isArray(data) && data.length > 0) {
setDatabase(String(data[0]));
} else if (inst?.db_type === 'redis') {
// Redis 默认 db0
setDatabase('db0');
}
})
.catch(() => {
// Fallback: no auto database list
setDatabases([]);
// Redis fallback
if (inst?.db_type === 'redis') {
setDatabase('db0');
}
})
.finally(() => setLoadingDatabases(false));
}, [instanceId]);
}, [instanceId, instances]);
/* ---------------------------------------------------------------- */
/* Schema browser: load tables */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!showSchema || !instanceId || !database) {
setTables([]);
return;
}
setLoadingTables(true);
instanceApi.listTables(Number(instanceId), database)
.then((res: unknown) => {
const data = (res as { tables?: TableMeta[] })?.tables
|| (res as TableMeta[])
|| [];
setTables(Array.isArray(data) ? data as TableMeta[] : []);
})
.catch(() => setTables([]))
.finally(() => setLoadingTables(false));
}, [showSchema, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Schema browser: load columns for a table */
/* ---------------------------------------------------------------- */
const handleExpandTable = useCallback(async (tableName: string) => {
if (expandedTable === tableName) {
setExpandedTable(null);
return;
}
setExpandedTable(tableName);
if (columns[tableName]) return; // already loaded
setLoadingColumns(tableName);
try {
const res = await instanceApi.listTables(Number(instanceId), `${database}/${tableName}`) as unknown;
const data = (res as { tables?: { columns?: { name: string }[] }[] })?.tables;
if (Array.isArray(data) && data[0]?.columns) {
const cols = data[0].columns.map((c: { name: string }) => c.name);
setColumns(prev => ({ ...prev, [tableName]: cols }));
}
} catch {
// ignore — columns not available
} finally {
setLoadingColumns(null);
}
}, [expandedTable, columns, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Insert table/column name into SQL editor */
/* ---------------------------------------------------------------- */
const insertToEditor = useCallback((text: string) => {
setSqlContent(prev => {
const separator = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
return prev + separator + text + ' ';
});
}, []);
/* ---------------------------------------------------------------- */
/* SQL change callback */
@@ -197,7 +327,6 @@ export default function TicketCreatePage() {
const handleSqlChange = useCallback((val: string) => {
setSqlContent(val);
// Auto-generate rollback suggestion when SQL changes
if (val.trim().length > 10) {
setRollbackSql(generateRollbackSuggestion(val));
}
@@ -255,7 +384,7 @@ export default function TicketCreatePage() {
const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({
title: title.trim(),
db_type: inst?.db_type || 'mysql',
db_type: inst?.db_type || dbType || 'mysql',
instance_id: Number(instanceId),
target_database: database,
sql_content: sqlContent,
@@ -270,7 +399,7 @@ export default function TicketCreatePage() {
} finally {
setSubmitting(false);
}
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]);
/* ---------------------------------------------------------------- */
/* Derived values */
@@ -336,6 +465,106 @@ export default function TicketCreatePage() {
</div>
) : null;
/* ---------------------------------------------------------------- */
/* Schema Browser Panel */
/* ---------------------------------------------------------------- */
const renderSchemaBrowser = () => {
if (!instanceId || !database) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg">
{/* Toggle button */}
<button
onClick={() => setShowSchema(!showSchema)}
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<FolderOpen className="w-4 h-4" />
浏览库表结构
<span className="text-xs text-[var(--dg-text-muted)]">— 点击表名/字段插入到 SQL</span>
{showSchema
? <ChevronUp className="w-4 h-4 ml-auto" />
: <ChevronDown className="w-4 h-4 ml-auto" />
}
</button>
{/* Schema content */}
{showSchema && (
<div className="px-4 pb-3 max-h-[300px] overflow-y-auto">
{loadingTables ? (
<div className="flex items-center gap-2 py-3 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" />
加载表列表...
</div>
) : tables.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">无可用表(或实例未连接)</p>
) : (
<div className="space-y-0.5">
{tables.map(t => (
<div key={t.name}>
{/* Table row */}
<button
onClick={() => handleExpandTable(t.name)}
className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded 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>
{t.rows !== undefined && (
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
{t.rows.toLocaleString()} 行
</span>
)}
</button>
{/* Columns */}
{expandedTable === t.name && (
<div className="ml-5 pl-3">
{loadingColumns === t.name ? (
<div className="flex items-center gap-2 py-1 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" />
加载字段...
</div>
) : columns[t.name] ? (
<div className="space-y-0.5">
{columns[t.name].map(col => (
<button
key={col}
onClick={() => insertToEditor(col)}
className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded hover:bg-[var(--dg-bg-hover)] transition-colors"
title="点击插入字段名"
>
<Columns3 className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 transition-colors">
{col}
</span>
</button>
))}
</div>
) : (
<p className="text-xs text-[var(--dg-text-muted)] py-1">字段信息不可用</p>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
/* ========== Step 1: Fill info ========== */
const renderStep1 = () => (
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
@@ -352,7 +581,7 @@ export default function TicketCreatePage() {
/>
</div>
{/* Priority + Instance */}
{/* Priority + DB Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
@@ -369,32 +598,65 @@ export default function TicketCreatePage() {
<div>
<label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span>
数据库类型 <span className="text-red-400">*</span>
</label>
<select
value={instanceId}
onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
value={dbType}
onChange={(e) => setDbType(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
>
<option value="">
{loadingInstances ? '加载中...' : '请选择实例'}
</option>
{instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
</option>
{DB_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Instance (filtered by dbType) */}
<div>
<label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span>
</label>
<select
value={instanceId}
onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances || filteredInstances.length === 0}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
>
<option value="">
{loadingInstances
? '加载中...'
: dbType && filteredInstances.length === 0
? `无 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
: dbType
? `请选择 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
: '请先选择数据库类型'
}
</option>
{filteredInstances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port})
</option>
))}
</select>
</div>
{/* Database */}
<div>
<label className="block text-sm font-medium mb-2">
数据库 <span className="text-red-400">*</span>
</label>
{databases.length > 0 ? (
{selectedInstance?.db_type === 'redis' ? (
<div className="flex items-center gap-2">
<input
value={database}
onChange={(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 focus:outline-none focus:border-blue-500 transition-colors"
placeholder="db0"
/>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">Redis</span>
</div>
) : databases.length > 0 ? (
<select
value={database}
onChange={(e) => setDatabase(e.target.value)}
@@ -410,22 +672,19 @@ export default function TicketCreatePage() {
)}
</select>
) : (
<div className="flex items-center gap-2">
<input
value={database}
onChange={(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 focus:outline-none focus:border-blue-500 transition-colors"
placeholder="目标数据库名"
/>
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{selectedInstance.db_type.toUpperCase()}
</span>
)}
</div>
<input
value={database}
onChange={(e) => setDatabase(e.target.value)}
disabled={!instanceId}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"
placeholder={instanceId ? '请输入数据库名' : '请先选择实例'}
/>
)}
</div>
{/* Schema Browser */}
{renderSchemaBrowser()}
{/* SQL Editor */}
<div>
<div className="flex items-center justify-between mb-2">
@@ -456,7 +715,7 @@ export default function TicketCreatePage() {
/>
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
<Info className="w-3 h-3" />
支持 Ctrl+Shift+F 格式化代码
提交前必须通过 SQL 审核(语法检查 + 审核规则)
</p>
</div>
@@ -500,12 +759,12 @@ export default function TicketCreatePage() {
{auditing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
审核中...
验证审核中...
</>
) : (
<>
<Shield className="w-4 h-4" />
审核并继续
验证并审核
</>
)}
</button>
@@ -649,7 +908,7 @@ export default function TicketCreatePage() {
{/* Rollback preview */}
{rollbackSql && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<div className="flex items-center gap-1.5 mb-1">
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>