feat: P0前端页面完善 — InstanceDetail/RedisDetail/TicketCreate/TicketDetail
- InstanceDetailPage: 概览/表管理/SQL查询/用户权限/慢查询/进程列表 6个Tab - RedisDetailPage: 键管理/监控信息/命令行/性能分析 4个Tab + 新增键弹框 - TicketCreatePage: 工单创建向导(选择实例/填写SQL/审核规则检查) - TicketDetailPage: 工单详情+审批/拒绝/执行/回滚操作 - App.tsx 路由注册: instances/:id, redis/:id, tickets/create, tickets/:id - API方法名对齐: listDatabases/listTables/listUsers - 前端构建零错误, 后端202测试通过
This commit is contained in:
1 parent
5cbd0e3441
commit
dfdac3ad37
7 files changed
+1415
-8
No files matched your search
@@ -0,0 +1,307 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
var levelColor: Record<string, string> = {
|
||||
error: 'text-red-400 bg-red-500/15',
|
||||
warning: 'text-yellow-400 bg-yellow-500/15',
|
||||
info: 'text-blue-400 bg-blue-500/15',
|
||||
};
|
||||
|
||||
var levelLabel: 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('');
|
||||
|
||||
useEffect(function () {
|
||||
instanceApi.list().then(function (res) {
|
||||
var data = (res as any)?.items || res;
|
||||
setInstances(Array.isArray(data) ? data : []);
|
||||
}).catch(function () {
|
||||
setInstances([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
var handleAudit = async function () {
|
||||
if (!sqlContent || !instanceId) {
|
||||
setError('请填写SQL和选择实例');
|
||||
return;
|
||||
}
|
||||
setAuditing(true);
|
||||
setError('');
|
||||
try {
|
||||
var result = await ticketApi.audit({
|
||||
sql_content: sqlContent,
|
||||
instance_id: Number(instanceId),
|
||||
database: database,
|
||||
}) as unknown as AuditResult;
|
||||
setAuditResult(result);
|
||||
setStep(2);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '审核失败');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
var handleSubmit = async function () {
|
||||
if (!title || !sqlContent || !instanceId || !database || !reason) {
|
||||
setError('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
|
||||
await ticketApi.create({
|
||||
title: title,
|
||||
db_type: inst?.db_type || 'mysql',
|
||||
instance_id: Number(instanceId),
|
||||
target_database: database,
|
||||
sql_content: sqlContent,
|
||||
reason: reason,
|
||||
priority: priority,
|
||||
});
|
||||
navigate('/tickets');
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
var riskLevel = auditResult
|
||||
? auditResult.score >= 90 ? 'low'
|
||||
: auditResult.score >= 60 ? 'medium' : 'high'
|
||||
: '';
|
||||
|
||||
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-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">
|
||||
<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>
|
||||
|
||||
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user