refactor: 数据源弹框交互重构 + test-connection API
1. 类型选择前置: 4个数据源类型卡片(MySQL/PG/Redis/Mongo)放最前面 2. 根据类型动态渲染: Redis隐藏用户名/数据库, 显示数据库编号 3. 环境支持自定义输入: 预设开发/预发/生产/测试/UAT + 自定义... 4. 去掉流程配置: 不再需要配置审批流程 5. 测试连接优先: 底部左侧「测试连接」按钮, 连接成功后才能「添加数据源」 6. 后端新增 POST /test-connection: 不保存实例直接测试连接参数 7. 类型切换自动填充默认端口: mysql:3306, pg:5432, redis:6379, mongo:27017 8. 连接测试结果展示: 成功(绿色+版本号) / 失败(红色+错误信息) 9. 修改连接参数时自动清除测试结果, 需重新测试 前端构建零错误, 后端202测试通过
This commit is contained in:
1 parent
f8ae3b92e5
commit
e0cb505d43
3 files changed
+176
-93
No files matched your search
@@ -90,6 +90,20 @@ async def create_instance(
|
|||||||
return _serialize_json_fields(instance)
|
return _serialize_json_fields(instance)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection", response_model=ConnectionTestResult)
|
||||||
|
async def test_new_connection(
|
||||||
|
data: InstanceCreate,
|
||||||
|
current_user: User = Depends(require_role("admin", "dba")),
|
||||||
|
):
|
||||||
|
"""测试连接参数(不保存实例)"""
|
||||||
|
fake = Instance(
|
||||||
|
name=data.name, db_type=data.db_type, host=data.host, port=data.port,
|
||||||
|
username=data.username, password=data.password, database=data.database,
|
||||||
|
env=data.env, access_mode=data.access_mode,
|
||||||
|
)
|
||||||
|
return await InstanceService.test_connection(fake)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instance_id}", response_model=InstanceResponse)
|
@router.get("/{instance_id}", response_model=InstanceResponse)
|
||||||
async def get_instance(
|
async def get_instance(
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const instanceApi = {
|
|||||||
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
|
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
|
||||||
delete: (id: number) => request.delete(`/instances/${id}`),
|
delete: (id: number) => request.delete(`/instances/${id}`),
|
||||||
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test`),
|
testConnection: (id: number) => request.post<any, any>(`/instances/${id}/test`),
|
||||||
|
testNewConnection: (data: any) => request.post<any, any>('/instances/test-connection', data),
|
||||||
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
|
listDatabases: (id: number) => request.get<any, string[]>(`/instances/${id}/databases`),
|
||||||
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
|
listTables: (id: number, db: string) => request.get<any, any[]>(`/instances/${id}/databases/${db}/tables`),
|
||||||
};
|
};
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
||||||
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
||||||
XCircle, Eye, EyeOff, X,
|
XCircle, Eye, EyeOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -324,59 +324,60 @@ const PRESET_MASK_FIELDS = [
|
|||||||
'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content',
|
'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content',
|
||||||
];
|
];
|
||||||
|
|
||||||
interface WorkflowStep {
|
const DB_DEFAULT_PORTS: Record<string, number> = {
|
||||||
stage: string;
|
mysql: 3306, postgres: 5432, redis: 6379, mongo: 27017,
|
||||||
role: string;
|
};
|
||||||
assignee: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) {
|
function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) {
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [dbType, setDbType] = useState(defaultType);
|
const [dbType, setDbType] = useState(defaultType);
|
||||||
|
const [name, setName] = useState('');
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
const [port, setPort] = useState('3306');
|
const [port, setPort] = useState(String(DB_DEFAULT_PORTS[defaultType] || 3306));
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPwd, setShowPwd] = useState(false);
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
const [database, setDatabase] = useState('');
|
const [database, setDatabase] = useState('');
|
||||||
const [env, setEnv] = useState('development');
|
const [env, setEnv] = useState('development');
|
||||||
|
const [customEnv, setCustomEnv] = useState('');
|
||||||
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
|
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
|
||||||
const [excludedDbs, setExcludedDbs] = useState('');
|
const [excludedDbs, setExcludedDbs] = useState('');
|
||||||
const [maskFields, setMaskFields] = useState('');
|
const [maskFields, setMaskFields] = useState('');
|
||||||
const [workflow, setWorkflow] = useState<WorkflowStep[]>([
|
|
||||||
{ stage: '提交阶段', role: '提交人', assignee: '' },
|
|
||||||
{ stage: '开发负责人', role: '开发负责人', assignee: '' },
|
|
||||||
{ stage: '执行人', role: '执行人', assignee: '' },
|
|
||||||
]);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||||
|
|
||||||
const updateWorkflow = (idx: number, field: keyof WorkflowStep, value: string) => {
|
const isRedis = dbType === 'redis';
|
||||||
setWorkflow(prev => prev.map((w, i) => i === idx ? { ...w, [field]: value } : w));
|
const isMongo = dbType === 'mongo';
|
||||||
};
|
const needUsername = !isRedis;
|
||||||
|
const needDatabase = !isRedis;
|
||||||
|
|
||||||
const addWorkflowStep = () => {
|
const finalEnv = env === '__custom__' ? customEnv.trim() : env;
|
||||||
setWorkflow(prev => [...prev, { stage: '', role: '', assignee: '' }]);
|
const canSubmit = testResult?.success === true;
|
||||||
};
|
|
||||||
|
|
||||||
const removeWorkflowStep = (idx: number) => {
|
const handleTypeChange = (t: string) => {
|
||||||
setWorkflow(prev => prev.filter((_, i) => i !== idx));
|
setDbType(t);
|
||||||
|
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
|
||||||
|
setTestResult(null);
|
||||||
|
if (t === 'redis') { setUsername(''); setDatabase(''); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; }
|
if (!name) { setError('请填写名称'); return; }
|
||||||
setSubmitting(true);
|
if (!host || !port) { setError('请填写地址和端口'); return; }
|
||||||
setError('');
|
if (needUsername && !username) { setError('请填写用户名'); return; }
|
||||||
|
setSubmitting(true); setError('');
|
||||||
try {
|
try {
|
||||||
await instanceApi.create({
|
await instanceApi.create({
|
||||||
name, db_type: dbType, host, port: Number(port),
|
name, db_type: dbType, host, port: Number(port),
|
||||||
username, password, database: database || undefined,
|
username: username || 'default', password: password || 'default',
|
||||||
env, access_mode: accessMode,
|
database: database || undefined,
|
||||||
workflow: workflow.filter(w => w.stage || w.assignee).length > 0 ? workflow.filter(w => w.stage || w.assignee) : undefined,
|
env: (finalEnv || 'development') as any,
|
||||||
|
access_mode: accessMode,
|
||||||
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
||||||
mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => {
|
mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => {
|
||||||
const [table = '', field = '', rule = 'mask'] = line.split(':');
|
const parts = line.split(':');
|
||||||
return { table: table.trim(), field: field.trim(), rule: rule.trim() || 'mask' };
|
return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' };
|
||||||
}) : undefined,
|
}) : undefined,
|
||||||
});
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
@@ -387,6 +388,26 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 测试连接(不保存)
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!host || !port) { setError('请填写地址和端口'); return; }
|
||||||
|
if (needUsername && !username) { setError('请填写用户名'); return; }
|
||||||
|
setTesting(true); setError(''); setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await instanceApi.testNewConnection({
|
||||||
|
name: name || '__test__', db_type: dbType, host, port: Number(port),
|
||||||
|
username: username || 'default', password: password || 'default',
|
||||||
|
database: database || undefined,
|
||||||
|
env: (finalEnv || 'development') as any, access_mode: accessMode,
|
||||||
|
});
|
||||||
|
setTestResult(res as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestResult({ success: false, message: e?.response?.data?.detail || '连接失败' });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none';
|
const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none';
|
||||||
const labelCls = 'block text-sm font-medium mb-1.5';
|
const labelCls = 'block text-sm font-medium mb-1.5';
|
||||||
|
|
||||||
@@ -399,63 +420,129 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
|
|
||||||
{/* 数据源信息 */}
|
{/* ① 类型选择 — 最前面 */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源信息</h3>
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源类型</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'mysql', label: 'MySQL', icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]' },
|
||||||
|
{ value: 'postgres', label: 'PostgreSQL', icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]' },
|
||||||
|
{ value: 'redis', label: 'Redis', icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]' },
|
||||||
|
{ value: 'mongo', label: 'MongoDB', icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]' },
|
||||||
|
].map(t => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
|
||||||
|
className={'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all '
|
||||||
|
+ (dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]')}>
|
||||||
|
<div className={'w-10 h-10 rounded-lg flex items-center justify-center ' + t.bg}>
|
||||||
|
<Icon className={'w-5 h-5 ' + t.color} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ② 基本信息 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">基本信息</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
|
||||||
<select value={env} onChange={e => setEnv(e.target.value)} className={inputCls}>
|
<input value={name} onChange={e => { setName(e.target.value); setTestResult(null); }} className={inputCls} placeholder="例如:prod-mysql-只读库" />
|
||||||
<option value="production">生产</option>
|
|
||||||
<option value="staging">预发</option>
|
|
||||||
<option value="development">开发</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
|
||||||
<input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="例如:prod-mysql-只读库" />
|
<div className="relative">
|
||||||
|
<select value={env} onChange={e => setEnv(e.target.value)} className={inputCls + (env === '__custom__' ? ' mb-2' : '')}>
|
||||||
|
<option value="development">开发</option>
|
||||||
|
<option value="staging">预发</option>
|
||||||
|
<option value="production">生产</option>
|
||||||
|
<option value="test">测试</option>
|
||||||
|
<option value="uat">UAT</option>
|
||||||
|
<option value="__custom__">自定义...</option>
|
||||||
|
</select>
|
||||||
|
{env === '__custom__' && (
|
||||||
|
<input autoFocus value={customEnv} onChange={e => setCustomEnv(e.target.value)} className={inputCls} placeholder="输入自定义环境名" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ③ 连接信息 — 根据类型动态渲染 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接信息</h3>
|
||||||
|
<div className={isRedis ? 'grid grid-cols-2 gap-4' : 'grid grid-cols-3 gap-4'}>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} className={inputCls} placeholder="例如:mysql.example.com" />
|
<input value={host} onChange={e => { setHost(e.target.value); setTestResult(null); }} className={inputCls} placeholder="例如:mysql.example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
|
||||||
<input value={port} onChange={e => setPort(e.target.value)} className={inputCls} placeholder="3306" />
|
<input value={port} onChange={e => { setPort(e.target.value); setTestResult(null); }} className={inputCls} placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{!isRedis && (
|
||||||
<label className={labelCls}>类型</label>
|
|
||||||
<select value={dbType} onChange={e => setDbType(e.target.value)} className={inputCls}>
|
|
||||||
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={inputCls} placeholder="admin" />
|
<input value={username} onChange={e => { setUsername(e.target.value); setTestResult(null); }} className={inputCls} placeholder="root" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{!isRedis ? (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>密码 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>密码 <span className="text-red-400">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPwd ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} className={inputCls + ' pr-10'} placeholder="数据库密码" />
|
<input type={showPwd ? 'text' : 'password'} value={password} onChange={e => { setPassword(e.target.value); setTestResult(null); }} className={inputCls + ' pr-10'} placeholder="数据库密码" />
|
||||||
<button type="button" onClick={() => setShowPwd(!showPwd)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
<button type="button" onClick={() => setShowPwd(!showPwd)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input type={showPwd ? 'text' : 'password'} value={password} onChange={e => { setPassword(e.target.value); setTestResult(null); }} className={inputCls + ' pr-10'} placeholder="可选,Redis 无密码可留空" />
|
||||||
|
<button type="button" onClick={() => setShowPwd(!showPwd)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||||
|
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needDatabase ? (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>默认数据库</label>
|
<label className={labelCls}>默认数据库</label>
|
||||||
<input value={database} onChange={e => setDatabase(e.target.value)} className={inputCls} placeholder="可选,留空则连接默认库" />
|
<input value={database} onChange={e => { setDatabase(e.target.value); setTestResult(null); }} className={inputCls} placeholder={isMongo ? '可选,如 mydb' : '可选,留空则连接默认库'} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>数据库编号</label>
|
||||||
|
<input className={inputCls} placeholder="默认 0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 连接测试结果 */}
|
||||||
|
{testResult && (
|
||||||
|
<div className={'flex items-center gap-3 p-3 rounded-lg text-sm ' + (testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400')}>
|
||||||
|
{testResult.success
|
||||||
|
? <><Activity className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}</>
|
||||||
|
: <><XCircle className="w-4 h-4 flex-shrink-0" /> {testResult.message}</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 连接模式 */}
|
{/* ④ 连接模式 */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接模式</h3>
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接模式</h3>
|
||||||
@@ -465,15 +552,11 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
{ value: 'write' as const, label: '写' },
|
{ value: 'write' as const, label: '写' },
|
||||||
{ value: 'read' as const, label: '读' },
|
{ value: 'read' as const, label: '读' },
|
||||||
].map(m => (
|
].map(m => (
|
||||||
<button
|
<button key={m.value} type="button" onClick={() => setAccessMode(m.value)}
|
||||||
key={m.value}
|
className={'px-5 py-2 rounded-lg text-sm font-medium transition-all '
|
||||||
onClick={() => setAccessMode(m.value)}
|
+ (accessMode === m.value
|
||||||
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
accessMode === m.value
|
|
||||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]')}>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -481,37 +564,16 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 流程配置 */}
|
{/* ⑤ 高级配置 */}
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">流程</h3>
|
|
||||||
<button onClick={addWorkflowStep} className="text-xs text-blue-400 hover:text-blue-300">+ 添加阶段</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{workflow.map((step, idx) => (
|
|
||||||
<div key={idx} className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] w-6 text-right">{idx + 1}</span>
|
|
||||||
<input value={step.stage} onChange={e => updateWorkflow(idx, 'stage', e.target.value)} className={inputCls + ' flex-1'} placeholder="提交阶段" />
|
|
||||||
<input value={step.role} onChange={e => updateWorkflow(idx, 'role', e.target.value)} className={inputCls + ' w-32'} placeholder="角色" />
|
|
||||||
<input value={step.assignee} onChange={e => updateWorkflow(idx, 'assignee', e.target.value)} className={inputCls + ' flex-1'} placeholder="负责人" />
|
|
||||||
{workflow.length > 1 && (
|
|
||||||
<button onClick={() => removeWorkflowStep(idx)} className="text-[var(--dg-text-muted)] hover:text-red-400"><X className="w-4 h-4" /></button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 排除的数据库 & 脱敏字段 */}
|
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
|
||||||
|
{!isRedis && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>排除的数据库</label>
|
<label className={labelCls}>排除的数据库</label>
|
||||||
<input value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
|
<input value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className={labelCls + ' mb-0'}>脱敏字段</label>
|
<label className={labelCls + ' mb-0'}>脱敏字段</label>
|
||||||
@@ -523,7 +585,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
});
|
});
|
||||||
}} id="copy-mask-btn" className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">复制全部</button>
|
}} id="copy-mask-btn" className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">复制全部</button>
|
||||||
<button type="button" onClick={function () {
|
<button type="button" onClick={function () {
|
||||||
var newFields = PRESET_MASK_FIELDS.join('\n');
|
var newFields = PRESET_MASK_FIELDS.map(function (f) { return f + ':mask'; }).join('\n');
|
||||||
setMaskFields(maskFields ? maskFields + '\n' + newFields : newFields);
|
setMaskFields(maskFields ? maskFields + '\n' + newFields : newFields);
|
||||||
}} className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-0.5 rounded bg-blue-500/10">一键填入</button>
|
}} className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-0.5 rounded bg-blue-500/10">一键填入</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,8 +597,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
<button key={f} type="button" onClick={function () {
|
<button key={f} type="button" onClick={function () {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
setMaskFields(maskFields.split('\n').filter(function (line) {
|
setMaskFields(maskFields.split('\n').filter(function (line) {
|
||||||
return !line.split(':')[1]?.trim()?.split(',').map(function (s) { return s.trim(); }).includes(f)
|
return !line.split(':')[0]?.trim()?.includes(f);
|
||||||
&& line.split(':')[0]?.trim() !== f;
|
|
||||||
}).join('\n'));
|
}).join('\n'));
|
||||||
} else {
|
} else {
|
||||||
setMaskFields(maskFields ? maskFields + '\n' + f + ':mask' : f + ':mask');
|
setMaskFields(maskFields ? maskFields + '\n' + f + ':mask' : f + ':mask');
|
||||||
@@ -551,11 +612,18 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
<button onClick={handleTestConnection} disabled={testing || !host || !port}
|
||||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white disabled:opacity-50">
|
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40">
|
||||||
{submitting ? '创建中...' : '添加数据源'}
|
<Zap className="w-4 h-4" /> {testing ? '测试中...' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||||
|
<button onClick={handleSubmit} disabled={submitting || !canSubmit}
|
||||||
|
className={'px-4 py-2 rounded-lg text-sm text-white transition-all ' + (canSubmit ? 'bg-blue-600 hover:bg-blue-700' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed')}>
|
||||||
|
{submitting ? '创建中...' : canSubmit ? '添加数据源' : '请先测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user