- access_type 从3值(readonly/datachange/ddl)简化为2值(readonly/write) - readonly=只读查看(关联只读/从库用于查询) write=写入变更(关联主库用于工单) - 前端InstanceGroupPage重写: 2权限类型卡片+自定义Transfer穿梭框组件 - Transfer组件: 左侧可选数据库→右侧已授权数据库, 搜索/勾选/箭头移动 - role_permissions.py更新: 2值权限映射, get_accessible_instances按access_type过滤 - 后端schema/模型校验更新为readonly|write正则 - 15个测试全部通过
195 lines
7.4 KiB
TypeScript
195 lines
7.4 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { X, Loader2 } from 'lucide-react';
|
|
import { instanceApi } from '@/api/instance';
|
|
import { instanceGroupApi } from '@/api/instanceGroup';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
interface InstanceData {
|
|
id: number;
|
|
name: string;
|
|
db_type: string;
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
database: string;
|
|
env: string;
|
|
access_mode: string;
|
|
group_id?: number | null;
|
|
}
|
|
|
|
interface PermGroup {
|
|
id: number;
|
|
name: string;
|
|
access_type?: string;
|
|
}
|
|
|
|
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 [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');
|
|
setError('');
|
|
}, [instance]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!name.trim()) { setError('请填写名称'); return; }
|
|
if (!host.trim()) { setError('请填写地址'); return; }
|
|
setSubmitting(true); setError('');
|
|
try {
|
|
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,
|
|
});
|
|
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, 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>
|
|
)}
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className={labelCls}>名称</label>
|
|
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
|
|
</div>
|
|
|
|
{/* Host + Port */}
|
|
<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>
|
|
|
|
{/* Username + Password */}
|
|
<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>
|
|
|
|
{/* Database */}
|
|
<div>
|
|
<label className={labelCls}>默认数据库</label>
|
|
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
|
|
</div>
|
|
|
|
{/* Env + Access Mode */}
|
|
<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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|