Files
opsxsql/frontend/src/components/EditInstanceDialog.tsx
T
logan 128de64592 feat: 数据源编辑加排除数据库+脱敏字段+审批流关联; 权限组Transfer改为选择实例
- EditInstanceDialog: 新增排除数据库(逗号分隔)、脱敏字段(动态表单+表/字段/规则)、审批流程关联
- Instance: workflow JSON字段改为workflow_id FK关联workflow_definitions表
- InstanceGroupPage: Transfer穿梭框从选数据库改为选实例(数据源)
- 后端instance API返回workflow_name, _get_workflow_names批量查询
- DB迁移: instances表添加workflow_id列
- 37个测试全部通过
2026-05-24 19:36:39 +08:00

264 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect } from 'react';
import { X, Loader2, Plus, Trash2 } from 'lucide-react';
import { instanceApi } from '@/api/instance';
import { workflowApi } from '@/api/workflow';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InstanceData {
id: number;
name: string;
db_type: string;
host: string;
port: number;
username: string;
database: string;
env: string;
access_mode: string;
workflow_id?: number | null;
workflow_name?: string | null;
excluded_databases?: string[] | null;
mask_fields?: { table: string; field: string; rule: string }[] | null;
group_id?: number | null;
}
interface WorkflowItem {
id: number;
name: string;
is_active?: boolean;
}
interface Props {
instance: InstanceData;
open: boolean;
onClose: () => void;
onSaved: () => void;
}
/* ------------------------------------------------------------------ */
/* Reusable Edit Instance Dialog */
/* ------------------------------------------------------------------ */
export default function EditInstanceDialog({ instance, open, onClose, onSaved }: Props) {
const [name, setName] = useState(instance.name);
const [host, setHost] = useState(instance.host);
const [port, setPort] = useState(String(instance.port));
const [username, setUsername] = useState(instance.username);
const [password, setPassword] = useState('');
const [database, setDatabase] = useState(instance.database || '');
const [env, setEnv] = useState(instance.env || 'development');
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
// excluded_databases: comma-separated input
const [excludedDbs, setExcludedDbs] = useState((instance.excluded_databases || []).join(', '));
// mask_fields
const [maskFields, setMaskFields] = useState<{ table: string; field: string; rule: string }[]>(
instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
/* Sync when instance changes */
useEffect(() => {
setName(instance.name);
setHost(instance.host);
setPort(String(instance.port));
setUsername(instance.username);
setPassword('');
setDatabase(instance.database || '');
setEnv(instance.env || 'development');
setAccessMode(instance.access_mode || 'readwrite');
setWorkflowId(instance.workflow_id || null);
setExcludedDbs((instance.excluded_databases || []).join(', '));
setMaskFields(instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []);
setError('');
}, [instance]);
/* Load workflows when modal opens */
useEffect(() => {
if (open) {
workflowApi.list().then((data: any) => {
const items = Array.isArray(data) ? data : (data?.items || []);
setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false));
}).catch(() => setWorkflows([]));
}
}, [open]);
const addMaskField = () => setMaskFields([...maskFields, { table: '', field: '', rule: 'mask' }]);
const removeMaskField = (idx: number) => setMaskFields(maskFields.filter((_, i) => i !== idx));
const updateMaskField = (idx: number, key: string, value: string) => {
const next = [...maskFields];
next[idx] = { ...next[idx], [key]: value };
setMaskFields(next);
};
const handleSubmit = useCallback(async () => {
if (!name.trim()) { setError('请填写名称'); return; }
if (!host.trim()) { setError('请填写地址'); return; }
setSubmitting(true); setError('');
try {
const parsedExcluded = excludedDbs.trim()
? excludedDbs.split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const parsedMask = maskFields.some(m => m.table && m.field)
? maskFields.filter(m => m.table && m.field)
: undefined;
await instanceApi.update(instance.id, {
name: name.trim(),
host: host.trim(),
port: Number(port),
username: username.trim() || undefined,
password: password || undefined,
database: database.trim() || undefined,
env,
access_mode: accessMode,
workflow_id: workflowId,
excluded_databases: parsedExcluded,
mask_fields: parsedMask,
});
onSaved();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
setError(msg);
} finally {
setSubmitting(false);
}
}, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]);
if (!open) return null;
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">编辑数据源</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 pb-5 space-y-4">
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
<div>
<label className={labelCls}>名称</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>地址</label>
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
</div>
<div>
<label className={labelCls}>端口</label>
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>用户名</label>
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
</div>
<div>
<label className={labelCls}>密码(留空不修改)</label>
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
</div>
</div>
<div>
<label className={labelCls}>默认数据库</label>
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>环境</label>
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
<option value="development">开发</option>
<option value="testing">测试</option>
<option value="staging">预发</option>
<option value="production">生产</option>
<option value="uat">UAT</option>
</select>
</div>
<div>
<label className={labelCls}>访问模式</label>
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
<option value="readwrite">读写</option>
<option value="read">只读</option>
<option value="write">只写</option>
</select>
</div>
</div>
{/* 审批流程 */}
<div>
<label className={labelCls}>审批流程</label>
<select className={inputCls} value={workflowId ?? ''} onChange={e => setWorkflowId(e.target.value ? Number(e.target.value) : null)}>
<option value="">不关联审批流程</option>
{workflows.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
</div>
{/* 排除数据库 */}
<div>
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
<input className={inputCls} value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} placeholder="sys, information_schema, mysql" />
</div>
{/* 脱敏字段 */}
<div>
<div className="flex items-center justify-between mb-1">
<label className={labelCls}>脱敏字段</label>
<button onClick={addMaskField} className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1">
<Plus className="w-3 h-3" /> 添加
</button>
</div>
{maskFields.length === 0 && (
<div className="text-xs text-[var(--dg-text-muted)] py-2">未配置脱敏规则</div>
)}
<div className="space-y-2">
{maskFields.map((mf, idx) => (
<div key={idx} className="flex items-center gap-2">
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.table} onChange={e => updateMaskField(idx, 'table', e.target.value)} placeholder="表名" />
<input className="flex-1 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.field} onChange={e => updateMaskField(idx, 'field', e.target.value)} placeholder="字段名" />
<select className="w-20 px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none" value={mf.rule} onChange={e => updateMaskField(idx, 'rule', e.target.value)}>
<option value="mask">遮盖</option>
<option value="hide">隐藏</option>
<option value="hash">哈希</option>
</select>
<button onClick={() => removeMaskField(idx)} className="p-1 text-[var(--dg-text-muted)] hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
保存
</button>
</div>
</div>
</div>
);
}