merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1): - mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5) - redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5) - mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能) - postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7) 后端合并 (Step 2): - audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则) - audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射 - audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb) 前端优化 (Step 4) — 16页面全覆盖: - InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage - UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard - InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage - useAuth.tsx, SqlEditor.tsx 后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16333
-2225
No files matched your search
@@ -1,8 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
|
||||
import {
|
||||
ChevronRight, Check, AlertTriangle, Shield, Database,
|
||||
FileText, Sparkles, RotateCcw, Loader2, Info,
|
||||
} from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import SqlEditor, { formatSQL } from '@/components/SqlEditor';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface InstanceItem {
|
||||
id: number;
|
||||
@@ -25,283 +33,741 @@ interface AuditResult {
|
||||
findings: AuditFinding[];
|
||||
}
|
||||
|
||||
var levelColor: Record<string, string> = {
|
||||
interface StepConfig {
|
||||
num: number;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STEPS: StepConfig[] = [
|
||||
{ num: 1, label: '填写信息', icon: FileText },
|
||||
{ num: 2, label: 'SQL 审核', icon: Shield },
|
||||
{ num: 3, label: '确认提交', icon: Check },
|
||||
];
|
||||
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
error: 'text-red-400 bg-red-500/15',
|
||||
warning: 'text-yellow-400 bg-yellow-500/15',
|
||||
warning: 'text-amber-400 bg-amber-500/15',
|
||||
info: 'text-blue-400 bg-blue-500/15',
|
||||
};
|
||||
|
||||
var levelLabel: Record<string, string> = {
|
||||
const LEVEL_LABEL: Record<string, string> = {
|
||||
error: '错误',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
};
|
||||
|
||||
export default function TicketCreatePage() {
|
||||
var navigate = useNavigate();
|
||||
var [step, setStep] = useState(1);
|
||||
var [title, setTitle] = useState('');
|
||||
var [priority, setPriority] = useState('medium');
|
||||
var [instanceId, setInstanceId] = useState('');
|
||||
var [database, setDatabase] = useState('');
|
||||
var [sqlContent, setSqlContent] = useState('');
|
||||
var [reason, setReason] = useState('');
|
||||
var [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
var [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||
var [auditing, setAuditing] = useState(false);
|
||||
var [submitting, setSubmitting] = useState(false);
|
||||
var [error, setError] = useState('');
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险',
|
||||
};
|
||||
|
||||
useEffect(function () {
|
||||
instanceApi.list().then(function (res) {
|
||||
var data = (res as any)?.items || res;
|
||||
setInstances(Array.isArray(data) ? data : []);
|
||||
}).catch(function () {
|
||||
setInstances([]);
|
||||
});
|
||||
const RISK_COLOR: Record<string, string> = {
|
||||
low: 'text-green-400 bg-green-500/15 border-green-500/30',
|
||||
medium: 'text-amber-400 bg-amber-500/15 border-amber-500/30',
|
||||
high: 'text-red-400 bg-red-500/15 border-red-500/30',
|
||||
};
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: '低' },
|
||||
{ value: 'medium', label: '中' },
|
||||
{ value: 'high', label: '高' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helper: generate rollback SQL suggestion */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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';
|
||||
const whereMatch = sql.match(/WHERE\s+(.+)/i);
|
||||
const where = whereMatch ? whereMatch[1].trim() : '';
|
||||
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';
|
||||
return `-- 建议回滚语句:\n-- DELETE FROM ${table} WHERE ...;\n-- 请根据 INSERT 条件编写对应的 DELETE 语句`;
|
||||
}
|
||||
|
||||
return `-- 请根据业务逻辑编写回滚语句`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function TicketCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// -- Step state --
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// -- Form fields --
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
const [database, setDatabase] = useState('');
|
||||
const [sqlContent, setSqlContent] = useState('');
|
||||
const [rollbackSql, setRollbackSql] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
// -- Data / Loading --
|
||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
const [databases, setDatabases] = useState<string[]>([]);
|
||||
const [loadingInstances, setLoadingInstances] = useState(false);
|
||||
const [loadingDatabases, setLoadingDatabases] = useState(false);
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// -- Audit --
|
||||
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch instances on mount */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingInstances(true);
|
||||
instanceApi.list()
|
||||
.then((res: unknown) => {
|
||||
const data = (res as { items?: unknown[] })?.items ?? res;
|
||||
setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
|
||||
})
|
||||
.catch(() => setInstances([]))
|
||||
.finally(() => setLoadingInstances(false));
|
||||
}, []);
|
||||
|
||||
var handleAudit = async function () {
|
||||
if (!sqlContent || !instanceId) {
|
||||
setError('请填写SQL和选择实例');
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch databases when instance changes */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) {
|
||||
setDatabases([]);
|
||||
setDatabase('');
|
||||
return;
|
||||
}
|
||||
setLoadingDatabases(true);
|
||||
instanceApi.getDatabases?.(Number(instanceId))
|
||||
.then((res: unknown) => {
|
||||
const data = (res as { databases?: string[] })?.databases
|
||||
|| (res as string[])
|
||||
|| [];
|
||||
setDatabases(Array.isArray(data) ? data as string[] : []);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setDatabase(String(data[0]));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: no auto database list
|
||||
setDatabases([]);
|
||||
})
|
||||
.finally(() => setLoadingDatabases(false));
|
||||
}, [instanceId]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* SQL change callback */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleSqlChange = useCallback((val: string) => {
|
||||
setSqlContent(val);
|
||||
// Auto-generate rollback suggestion when SQL changes
|
||||
if (val.trim().length > 10) {
|
||||
setRollbackSql(generateRollbackSuggestion(val));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Format SQL */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleFormatSQL = useCallback(() => {
|
||||
if (!sqlContent.trim()) return;
|
||||
const formatted = formatSQL(sqlContent);
|
||||
setSqlContent(formatted);
|
||||
}, [sqlContent]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Audit handler */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleAudit = useCallback(async () => {
|
||||
if (!sqlContent.trim() || !instanceId) {
|
||||
setError('请填写 SQL 语句并选择实例');
|
||||
return;
|
||||
}
|
||||
setAuditing(true);
|
||||
setError('');
|
||||
try {
|
||||
var result = await ticketApi.audit({
|
||||
const result = await ticketApi.audit({
|
||||
sql_content: sqlContent,
|
||||
instance_id: Number(instanceId),
|
||||
database: database,
|
||||
database,
|
||||
}) as unknown as AuditResult;
|
||||
setAuditResult(result);
|
||||
setStep(2);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '审核失败');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } } };
|
||||
setError(err?.response?.data?.detail || '审核失败,请检查 SQL 语法');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
};
|
||||
}, [sqlContent, instanceId, database]);
|
||||
|
||||
var handleSubmit = async function () {
|
||||
if (!title || !sqlContent || !instanceId || !database || !reason) {
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Pre-submit audit check */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handlePreSubmitAudit = useCallback(async () => {
|
||||
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
|
||||
setError('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
// If already audited and SQL hasn't changed, go directly to step 3
|
||||
if (auditResult) {
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
// Auto-audit before submission
|
||||
setAuditing(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await ticketApi.audit({
|
||||
sql_content: sqlContent,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
}) as unknown as AuditResult;
|
||||
setAuditResult(result);
|
||||
setStep(3);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } } };
|
||||
setError(err?.response?.data?.detail || '预审核失败,请检查 SQL 语法');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
}, [title, sqlContent, instanceId, database, reason, auditResult]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Submit handler */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
|
||||
setError('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
|
||||
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||
await ticketApi.create({
|
||||
title: title,
|
||||
title: title.trim(),
|
||||
db_type: inst?.db_type || 'mysql',
|
||||
instance_id: Number(instanceId),
|
||||
target_database: database,
|
||||
sql_content: sqlContent,
|
||||
reason: reason,
|
||||
rollback_sql: rollbackSql || undefined,
|
||||
reason: reason.trim(),
|
||||
priority: priority,
|
||||
});
|
||||
navigate('/tickets');
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '提交失败');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } } };
|
||||
setError(err?.response?.data?.detail || '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derived values */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const selectedInstance = useMemo(
|
||||
() => instances.find((i) => i.id === Number(instanceId)),
|
||||
[instances, instanceId]
|
||||
);
|
||||
|
||||
const riskLevel = useMemo(() => {
|
||||
if (!auditResult) return '';
|
||||
if (auditResult.score >= 90) return 'low';
|
||||
if (auditResult.score >= 60) return 'medium';
|
||||
return 'high';
|
||||
}, [auditResult]);
|
||||
|
||||
const isStep1Valid = title.trim() && sqlContent.trim() && instanceId && database && reason.trim();
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render helpers */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
|
||||
{STEPS.map((s, idx) => {
|
||||
const isActive = step === s.num;
|
||||
const isDone = step > s.num;
|
||||
const StepIcon = s.icon;
|
||||
|
||||
return (
|
||||
<div key={s.num} className="flex items-center flex-1">
|
||||
<div
|
||||
className={`flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 shadow-sm'
|
||||
: isDone
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'text-[var(--dg-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<StepIcon className="w-4 h-4" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
</div>
|
||||
{idx < STEPS.length - 1 && (
|
||||
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderError = () =>
|
||||
error ? (
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-3 rounded-lg border border-red-500/20">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
/* ========== Step 1: Fill info ========== */
|
||||
const renderStep1 = () => (
|
||||
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
工单标题 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
placeholder="请输入工单标题,如:用户表添加索引"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority + Instance */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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 ? '加载中...' : '请选择实例'}
|
||||
</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={String(inst.id)}>
|
||||
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
数据库 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{databases.length > 0 ? (
|
||||
<select
|
||||
value={database}
|
||||
onChange={(e) => setDatabase(e.target.value)}
|
||||
disabled={loadingDatabases}
|
||||
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"
|
||||
>
|
||||
{loadingDatabases ? (
|
||||
<option>加载中...</option>
|
||||
) : (
|
||||
databases.map((db) => (
|
||||
<option key={db} value={db}>{db}</option>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SQL Editor */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium">
|
||||
SQL 语句 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleFormatSQL}
|
||||
disabled={!sqlContent.trim()}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-40"
|
||||
title="格式化 SQL (Ctrl+Shift+F)"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
格式化
|
||||
</button>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SqlEditor
|
||||
value={sqlContent}
|
||||
onChange={handleSqlChange}
|
||||
language="sql"
|
||||
height="220px"
|
||||
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..."
|
||||
/>
|
||||
<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 格式化代码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rollback SQL */}
|
||||
{rollbackSql && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RotateCcw className="w-4 h-4 text-purple-400" />
|
||||
<label className="text-sm font-medium">回滚 SQL(自动建议)</label>
|
||||
</div>
|
||||
<SqlEditor
|
||||
value={rollbackSql}
|
||||
onChange={setRollbackSql}
|
||||
language="sql"
|
||||
height="100px"
|
||||
placeholder="请输入回滚 SQL 语句..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
变更原因 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:outline-none focus:border-blue-500 transition-colors"
|
||||
placeholder="请描述变更原因、影响范围、回滚方案等"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleAudit}
|
||||
disabled={auditing || !isStep1Valid}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{auditing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
审核中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4" />
|
||||
审核并继续
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ========== Step 2: Audit result ========== */
|
||||
const renderStep2 = () => {
|
||||
if (!auditResult) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
{/* Header with score */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--dg-border)]">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
SQL 审核结果
|
||||
</h3>
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ${RISK_COLOR[riskLevel] || ''}`}>
|
||||
<span className="text-2xl font-bold">{auditResult.score}</span>
|
||||
<span>分 · {RISK_LABEL[riskLevel] || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{auditResult.findings.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
|
||||
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-lg font-medium">审核通过,未发现风险</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">SQL 语句符合规范要求</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{auditResult.findings.map((f, idx) => (
|
||||
<div key={idx} className={`flex items-start gap-3 p-3 rounded-lg ${LEVEL_COLOR[f.level] || ''}`}>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{LEVEL_LABEL[f.level] || f.level}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="font-mono text-xs opacity-80">{f.rule}</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">错误</div>
|
||||
<div className="text-lg font-semibold text-red-400 mt-0.5">
|
||||
{auditResult.findings.filter((f) => f.level === 'error').length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">警告</div>
|
||||
<div className="text-lg font-semibold text-amber-400 mt-0.5">
|
||||
{auditResult.findings.filter((f) => f.level === 'warning').length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">提示</div>
|
||||
<div className="text-lg font-semibold text-blue-400 mt-0.5">
|
||||
{auditResult.findings.filter((f) => f.level === 'info').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all"
|
||||
>
|
||||
下一步:确认提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
var riskLevel = auditResult
|
||||
? auditResult.score >= 90 ? 'low'
|
||||
: auditResult.score >= 60 ? 'medium' : 'high'
|
||||
: '';
|
||||
/* ========== Step 3: Confirm ========== */
|
||||
const renderStep3 = () => {
|
||||
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||
|
||||
var riskLabel: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险' };
|
||||
var riskColor: Record<string, string> = {
|
||||
low: 'text-green-400 bg-green-500/15 border-green-500/30',
|
||||
medium: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
|
||||
high: 'text-red-400 bg-red-500/15 border-red-500/30',
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-green-400" />
|
||||
确认工单信息
|
||||
</h3>
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">标题</span>
|
||||
<p className="font-medium mt-1">{title}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">优先级</span>
|
||||
<p className="font-medium mt-1">
|
||||
{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">实例</span>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Database className="w-3.5 h-3.5 text-[var(--dg-text-secondary)]" />
|
||||
<span className="font-medium">{inst?.name || instanceId}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
({inst?.host}:{inst?.port})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-[var(--dg-text-muted)] text-xs">数据库</span>
|
||||
<p className="font-medium mt-1">{database}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL Preview */}
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">SQL 语句</span>
|
||||
<pre className="mt-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
||||
{sqlContent}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Rollback preview */}
|
||||
{rollbackSql && (
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
|
||||
<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>
|
||||
</div>
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap text-purple-300/80">
|
||||
{rollbackSql}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">变更原因</span>
|
||||
<p className="mt-1 text-sm">{reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Audit badge */}
|
||||
{auditResult && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Shield className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[var(--dg-text-muted)]">审核评分:</span>
|
||||
<span className={`font-medium ${RISK_COLOR[riskLevel]?.split(' ')[0] || ''}`}>
|
||||
{auditResult.score} 分 · {RISK_LABEL[riskLevel]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
<>确认提交</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Main render */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all"
|
||||
aria-label="返回工单列表"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
|
||||
{[
|
||||
{ num: 1, label: '填写信息' },
|
||||
{ num: 2, label: 'SQL审核' },
|
||||
{ num: 3, label: '确认提交' },
|
||||
].map(function (s, idx) {
|
||||
var isActive = step === s.num;
|
||||
var isDone = step > s.num;
|
||||
return (
|
||||
<div key={s.num} className="flex items-center flex-1">
|
||||
<div className={'flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-colors ' + (isActive ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : isDone ? 'bg-green-500/10 text-green-400' : 'text-[var(--dg-text-muted)]')}>
|
||||
{isDone ? <Check className="w-4 h-4" /> : <span className="w-5 h-5 rounded-full border text-xs flex items-center justify-center">{s.num}</span>}
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
</div>
|
||||
{idx < 2 && <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Steps */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
|
||||
{/* Error */}
|
||||
{renderError()}
|
||||
|
||||
{/* Step 1: Fill info */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
|
||||
<input value={title} onChange={function (e) { setTitle(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="请输入工单标题" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
<select value={priority} onChange={function (e) { setPriority(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">选择实例 <span className="text-red-400">*</span></label>
|
||||
<select value={instanceId} onChange={function (e) { setInstanceId(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="">请选择实例</option>
|
||||
{instances.map(function (inst) {
|
||||
return <option key={inst.id} value={String(inst.id)}>{inst.name} ({inst.host}:{inst.port})</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库 <span className="text-red-400">*</span></label>
|
||||
<input value={database} onChange={function (e) { setDatabase(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="目标数据库名" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL语句 <span className="text-red-400">*</span></label>
|
||||
<textarea value={sqlContent} onChange={function (e) { setSqlContent(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[180px] resize-y" placeholder="请输入SQL语句" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
||||
<textarea value={reason} onChange={function (e) { setReason(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y" placeholder="请描述变更原因" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button onClick={handleAudit} disabled={auditing} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
||||
{auditing ? '审核中...' : '开始审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Audit result */}
|
||||
{step === 2 && auditResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
SQL审核结果
|
||||
</h3>
|
||||
<div className={'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ' + riskColor[riskLevel]}>
|
||||
<span className="text-2xl font-bold">{auditResult.score}</span>
|
||||
<span>分 · {riskLabel[riskLevel]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auditResult.findings.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
|
||||
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-lg font-medium">审核通过,未发现风险</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{auditResult.findings.map(function (f, idx) {
|
||||
return (
|
||||
<div key={idx} className={'flex items-start gap-3 p-3 rounded-lg ' + (levelColor[f.level] || '')}>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{levelLabel[f.level] || f.level}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="font-mono text-xs">{f.rule}</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={function () { setStep(1); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
上一步
|
||||
</button>
|
||||
<button onClick={function () { setStep(3); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
|
||||
下一步:确认提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm and submit */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-lg font-semibold">确认工单信息</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">标题</span>
|
||||
<p className="font-medium mt-1">{title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">优先级</span>
|
||||
<p className="font-medium mt-1">{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">实例</span>
|
||||
<p className="font-medium mt-1">{instances.find(function (i) { return i.id === Number(instanceId); })?.name || instanceId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">数据库</span>
|
||||
<p className="font-medium mt-1">{database}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">SQL语句</span>
|
||||
<pre className="mt-1 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{sqlContent}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">变更原因</span>
|
||||
<p className="mt-1 text-sm">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={function () { setStep(2); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
上一步
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
||||
{submitting ? '提交中...' : '确认提交'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Step content */}
|
||||
{step === 1 && renderStep1()}
|
||||
{step === 2 && renderStep2()}
|
||||
{step === 3 && renderStep3()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user