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:
logan committed 2026-05-22 23:10:14 +08:00
1 parent 5cbd0e3441
commit dfdac3ad37
7 files changed
+1415 -8

No files matched your search

+307
View File
@@ -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>
);
}