Files
opsxsql/frontend/src/pages/InstancePage.tsx
T
logan 08f0f88cf1 feat: 工单列表重设计+权限组实例过滤+通知修复
后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
2026-05-24 22:55:51 +08:00

1453 lines
69 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
Activity, HardDrive, ChevronRight, Trash2, Edit2,
XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi,
WifiOff, AlertTriangle, CheckCircle, Signal,
LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { instanceApi } from '@/api/instance';
import EditInstanceDialog from '@/components/EditInstanceDialog';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InstanceItem {
id: number;
name: string;
db_type: string;
host: string;
port: number;
username: string;
database: string | null;
env: string;
description: string | null;
status: string;
version: string | null;
created_at: string;
connections?: number;
qps?: number;
latency?: number;
cpu?: number;
memory?: number;
disk?: number;
}
type ViewMode = 'grid' | 'list';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* DB Brand Icons */
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
};
const envLabel: Record<string, string> = {
production: '生产', staging: '预发', development: '开发', test: '测试', uat: 'UAT',
};
const envColor: Record<string, string> = {
production: 'bg-red-500/10 text-red-400 border-red-500/20',
staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
development: 'bg-green-500/10 text-green-400 border-green-500/20',
test: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
uat: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
};
const statusLabel: Record<string, { text: string; online: boolean }> = {
connected: { text: '在线', online: true },
online: { text: '在线', online: true },
disconnected: { text: '离线', online: false },
offline: { text: '离线', online: false },
error: { text: '异常', online: false },
};
function getStatusInfo(status: string) {
return statusLabel[status] || { text: '未知', online: false };
}
/* ------------------------------------------------------------------ */
/* Health Score */
/* ------------------------------------------------------------------ */
function calcHealthScore(inst: InstanceItem): number {
if (inst.status === 'offline' || inst.status === 'disconnected' || inst.status === 'error') return 0;
let score = 100;
const cpu = inst.cpu ?? 0;
const memory = inst.memory ?? 0;
const latency = inst.latency ?? 0;
const disk = inst.disk ?? 0;
if (cpu > 80) score -= 25;
else if (cpu > 60) score -= 10;
if (memory > 85) score -= 20;
else if (memory > 70) score -= 10;
if (latency > 10) score -= 20;
else if (latency > 5) score -= 10;
if (disk > 80) score -= 15;
return Math.max(0, score);
}
function healthColor(score: number): string {
if (score >= 90) return 'text-green-400';
if (score >= 70) return 'text-yellow-400';
if (score >= 50) return 'text-orange-400';
return 'text-red-400';
}
function healthBg(score: number): string {
if (score >= 90) return 'bg-green-500/15';
if (score >= 70) return 'bg-yellow-500/15';
if (score >= 50) return 'bg-orange-500/15';
return 'bg-red-500/15';
}
function healthLabel(score: number): string {
if (score >= 90) return '健康';
if (score >= 70) return '良好';
if (score >= 50) return '一般';
return '危险';
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function InstancePage() {
const canAdd = useActionPermission('instance.add');
const canEdit = useActionPermission('instance.edit');
const canDelete = useActionPermission('instance.delete');
const [activeType, setActiveType] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterEnv, setFilterEnv] = useState('');
const [selectedInstance, setSelectedInstance] = useState<InstanceItem | null>(null);
const [view, setView] = useState<'grid' | 'detail'>('grid');
const [layoutMode, setLayoutMode] = useState<ViewMode>('grid');
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [showClone, setShowClone] = useState(false);
const [cloneSource, setCloneSource] = useState<InstanceItem | null>(null);
const typeTabs = [
{ key: 'all', label: '全部', icon: LayoutGrid },
{ key: 'mysql', label: 'MySQL', icon: Database },
{ key: 'postgres', label: 'PostgreSQL', icon: Server },
{ key: 'redis', label: 'Redis', icon: Zap },
{ key: 'mongo', label: 'MongoDB', icon: Leaf },
];
const fetchInstances = useCallback(async () => {
setLoading(true);
try {
const res = await instanceApi.list({});
const data = Array.isArray(res) ? res : [];
setInstances(data);
} catch {
/* keep existing */
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchInstances(); }, [fetchInstances]);
const filtered = useMemo(() => {
return instances.filter(i => {
if (activeType !== 'all' && i.db_type !== activeType) return false;
if (filterStatus && i.status !== filterStatus) return false;
if (filterEnv && i.env !== filterEnv) return false;
if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false;
return true;
});
}, [instances, activeType, filterStatus, filterEnv, searchQuery]);
const statusCounts = useMemo(() => ({
online: filtered.filter(i => getStatusInfo(i.status).online).length,
offline: filtered.filter(i => !getStatusInfo(i.status).online).length,
total: filtered.length,
}), [filtered]);
const avgHealth = useMemo(() => {
if (filtered.length === 0) return 0;
const total = filtered.reduce((sum, i) => sum + calcHealthScore(i), 0);
return Math.round(total / filtered.length);
}, [filtered]);
const handleDelete = useCallback(async (id: number) => {
if (!confirm('确定删除该实例?')) return;
try {
await instanceApi.delete(id);
fetchInstances();
} catch {
/* ignore */
}
}, [fetchInstances]);
const handleBatchDelete = useCallback(async () => {
if (selectedIds.size === 0) return;
if (!confirm(`确定删除选中的 ${selectedIds.size} 个实例?此操作不可恢复。`)) return;
setBatchDeleting(true);
try {
const ids = Array.from(selectedIds);
await Promise.all(ids.map(id => instanceApi.delete(id)));
setSelectedIds(new Set());
fetchInstances();
} catch {
/* ignore */
} finally {
setBatchDeleting(false);
}
}, [selectedIds, fetchInstances]);
const toggleSelect = useCallback((id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const selectAll = useCallback(() => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map(f => f.id)));
}
}, [filtered, selectedIds.size]);
if (showCreate) {
return (
<CreateInstanceForm
defaultType={activeType === 'all' ? 'mysql' : activeType}
onCancel={() => setShowCreate(false)}
onCreated={() => { setShowCreate(false); fetchInstances(); }}
/>
);
}
if (showEdit && editingInstance) {
return (
<>
<InstanceDetail
instance={editingInstance}
onBack={() => { setView('grid'); setShowEdit(false); setEditingInstance(null); }}
config={dbTypeConfig[editingInstance.db_type] || dbTypeConfig.mysql}
canEdit={canEdit}
canDelete={canDelete}
onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }}
onEdit={() => {}}
onClone={canAdd ? () => { setCloneSource(editingInstance); setShowClone(true); } : undefined}
/>
<EditInstanceDialog
instance={editingInstance}
open={showEdit}
onClose={() => { setShowEdit(false); setEditingInstance(null); }}
onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }}
/>
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</>
);
}
const selectedConfig = selectedInstance
? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql)
: dbTypeConfig.mysql;
if (view === 'detail' && selectedInstance) {
return (
<>
<InstanceDetail
instance={selectedInstance}
onBack={() => setView('grid')}
config={selectedConfig}
canEdit={canEdit}
canDelete={canDelete}
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }}
onClone={canAdd ? () => { setCloneSource(selectedInstance); setShowClone(true); } : undefined}
/>
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</>
);
}
return (
<div className="space-y-5">
<PageHeader
title="数据源管理"
description="管理所有数据库实例,监控连接状态与性能指标"
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }}
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
/>
{/* Type Tabs */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
{typeTabs.map(tab => (
<button
key={tab.key}
onClick={() => { setActiveType(tab.key); setSelectedIds(new Set()); }}
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
activeType === tab.key
? 'bg-blue-600 text-white shadow-sm'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg">
<button
onClick={() => setLayoutMode('grid')}
className={`p-1.5 rounded-md transition-colors ${layoutMode === 'grid' ? 'bg-[var(--dg-bg-primary)] text-blue-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setLayoutMode('list')}
className={`p-1.5 rounded-md transition-colors ${layoutMode === 'list' ? 'bg-[var(--dg-bg-primary)] text-blue-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}
>
<ListFilter className="w-4 h-4" />
</button>
</div>
</div>
{/* Status Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Wifi className="w-5 h-5 text-green-400" />
</div>
<div>
<div className="text-2xl font-bold">{statusCounts.online}</div>
<div className="text-xs text-[var(--dg-text-muted)]">在线实例</div>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
<WifiOff className="w-5 h-5 text-red-400" />
</div>
<div>
<div className="text-2xl font-bold">{statusCounts.offline}</div>
<div className="text-xs text-[var(--dg-text-muted)]">离线实例</div>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Server className="w-5 h-5 text-blue-400" />
</div>
<div>
<div className="text-2xl font-bold">{statusCounts.total}</div>
<div className="text-xs text-[var(--dg-text-muted)]">总实例数</div>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${healthBg(avgHealth)} flex items-center justify-center`}>
<Gauge className={`w-5 h-5 ${healthColor(avgHealth)}`} />
</div>
<div>
<div className={`text-2xl font-bold ${healthColor(avgHealth)}`}>{avgHealth}</div>
<div className="text-xs text-[var(--dg-text-muted)]">平均健康分</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none transition-colors"
placeholder="搜索实例名称/地址"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="error">异常</option>
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={filterEnv}
onChange={e => setFilterEnv(e.target.value)}
>
<option value="">全部环境</option>
<option value="production">生产</option>
<option value="staging">预发</option>
<option value="development">开发</option>
<option value="test">测试</option>
<option value="uat">UAT</option>
</select>
{(searchQuery || filterStatus || filterEnv) && (
<button
onClick={() => { setSearchQuery(''); setFilterStatus(''); setFilterEnv(''); }}
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<X className="w-3 h-3" />
清除筛选
</button>
)}
</div>
{/* Batch Operations */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<div className="flex items-center gap-3">
<span className="text-sm text-blue-400 font-medium">已选择 {selectedIds.size} 个实例</span>
<button
onClick={selectAll}
className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
>
{selectedIds.size === filtered.length ? '取消全选' : '全选'}
</button>
</div>
{canDelete && (
<button
onClick={handleBatchDelete}
disabled={batchDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-500/15 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/25 transition-all disabled:opacity-50"
>
{batchDeleting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
{batchDeleting ? '删除中...' : '批量删除'}
</button>
)}
</div>
)}
{/* Instance Display */}
{loading && instances.length === 0 ? (
<div className="flex items-center justify-center p-10">
<Loader2 className="w-6 h-6 animate-spin text-blue-400 mr-2" />
<span className="text-sm text-[var(--dg-text-muted)]">加载中...</span>
</div>
) : filtered.length === 0 ? (
<EmptyState type="search" />
) : layoutMode === 'grid' ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filtered.map(instance => {
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
const IconComp = ic.icon;
const health = calcHealthScore(instance);
const info = getStatusInfo(instance.status);
const isSelected = selectedIds.has(instance.id);
return (
<div
key={instance.id}
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
isSelected ? 'ring-2 ring-blue-500/50' : ''
}`}
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
>
{/* Selection checkbox */}
<div
className="absolute top-3 left-3 z-10"
onClick={e => { e.stopPropagation(); toggleSelect(instance.id); }}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
}`}>
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
</div>
</div>
<div className="flex items-start justify-between mb-4 pl-6">
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
<IconComp className="w-5 h-5 object-contain" />
</div>
<div className="flex items-center gap-2">
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
{envLabel[instance.env] || instance.env}
</span>
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium ${
info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
}`}>
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{info.text}
</div>
</div>
</div>
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
{ic.label} {instance.version || ''}
</p>
{/* Health Score */}
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-[var(--dg-text-muted)]">健康评分</span>
<span className={`text-xs font-bold ${healthColor(health)}`}>{health} <span className="font-normal text-[var(--dg-text-muted)]">{healthLabel(health)}</span></span>
</div>
<div className="h-1.5 rounded-full bg-[var(--dg-bg-primary)] mt-1 overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${
health >= 90 ? 'bg-green-400' : health >= 70 ? 'bg-yellow-400' : health >= 50 ? 'bg-orange-400' : 'bg-red-400'
}`} style={{ width: `${health}%` }} />
</div>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-3 gap-1.5 text-center">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-[11px] font-semibold">{instance.connections}</span>
</div>
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
</div>
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
</div>
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
</div>
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
</div>
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
</div>
</div>
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-between">
<span className="text-[11px] text-blue-400 flex items-center gap-1">
查看详情
<ChevronRight className="w-3 h-3" />
</span>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
{canAdd && (
<button
onClick={() => {
setCloneSource(instance);
setShowClone(true);
}}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="克隆实例"
>
<Copy className="w-3.5 h-3.5" />
</button>
)}
{canEdit && (
<button
onClick={() => { setEditingInstance(instance); setShowEdit(true); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="编辑实例"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
/* List Mode */
<Card className="dg-card">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-3 w-8">
<div
onClick={selectAll}
className={`w-4 h-4 rounded border-2 flex items-center justify-center cursor-pointer transition-all ${
selectedIds.size === filtered.length && filtered.length > 0 ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
}`}
>
{selectedIds.size === filtered.length && filtered.length > 0 && <CheckCircle className="w-3 h-3 text-white" />}
</div>
</th>
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
<th className="text-left px-4 py-3 font-semibold">类型</th>
<th className="text-left px-4 py-3 font-semibold">地址</th>
<th className="text-left px-4 py-3 font-semibold">环境</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">健康分</th>
<th className="text-left px-4 py-3 font-semibold">连接/QPS</th>
<th className="text-left px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(instance => {
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
const IconComp = ic.icon;
const health = calcHealthScore(instance);
const info = getStatusInfo(instance.status);
const isSelected = selectedIds.has(instance.id);
return (
<tr
key={instance.id}
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
>
<td className="px-4 py-3" onClick={e => { e.stopPropagation(); toggleSelect(instance.id); }}>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
}`}>
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
</div>
</td>
<td className="px-4 py-3 font-medium">{instance.name}</td>
<td className="px-4 py-3">
<span className={`flex items-center gap-1.5 ${ic.color}`}>
<IconComp className="w-4 h-4 object-contain" />
<span className="text-xs">{ic.label}</span>
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)]">{instance.host}:{instance.port}</td>
<td className="px-4 py-3">
<span className={`text-[10px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || ''}`}>
{envLabel[instance.env] || instance.env}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium ${info.online ? 'text-green-400' : 'text-red-400'}`}>
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{info.text}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold ${healthColor(health)}`}>{health}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span>{instance.connections} 连接</span>
<span>{instance.qps?.toLocaleString()} QPS</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={e => { e.stopPropagation(); setSelectedInstance(instance); setView('detail'); }}
className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
>
查看
</button>
{canAdd && (
<button
onClick={e => { e.stopPropagation(); setCloneSource(instance); setShowClone(true); }}
className="text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="克隆实例"
>
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Clone Dialog */}
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* InstanceDetail */
/* ------------------------------------------------------------------ */
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit, onClone }: {
instance: InstanceItem; onBack: () => void;
config: { icon: React.ElementType; color: string; bg: string };
canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; onClone?: () => void;
}) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
const health = calcHealthScore(instance);
const info = getStatusInfo(instance.status);
const DetailIcon = config.icon;
const handleTest = useCallback(async () => {
setTesting(true);
try {
const res = await instanceApi.testConnection(instance.id);
setTestResult(res as { success: boolean; message: string; version?: string });
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
setTestResult({ success: false, message: msg });
} finally {
setTesting(false);
}
}, [instance.id]);
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl ${config.bg} flex items-center justify-center`}>
<DetailIcon className={`w-5 h-5 ${config.color}`} />
</div>
<div>
<h1 className="text-xl font-semibold flex items-center gap-3">
{instance.name}
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{info.text}
</span>
</h1>
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.host}:{instance.port} · {instance.version || '版本未知'}</p>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleTest}
disabled={testing}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all disabled:opacity-50"
>
<Zap className="w-4 h-4" />
{testing ? '测试中...' : '测试连接'}
</button>
{onClone && (
<button onClick={onClone} 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 text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Copy className="w-4 h-4" />
克隆
</button>
)}
{canEdit && (
<button onClick={onEdit} 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 text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Edit2 className="w-4 h-4" />
编辑
</button>
)}
{canDelete && (
<button
onClick={onDelete}
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 text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
删除
</button>
)}
</div>
</div>
{/* Connection Test Result */}
{testResult && (
<Card className={`dg-card ${testResult.success ? 'border-green-500/30' : 'border-red-500/30'}`}>
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${testResult.success ? 'bg-green-500/15' : 'bg-red-500/15'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4 text-green-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
</div>
<div>
<div className={`text-sm font-medium ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>{testResult.message}</div>
{testResult.version && <div className="text-xs text-[var(--dg-text-muted)]">版本: {testResult.version}</div>}
</div>
</CardContent>
</Card>
)}
{/* Health + Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Health Score Card */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
健康评分
</CardTitle>
</CardHeader>
<CardContent className="p-5">
<div className="flex items-center justify-center py-4">
<div className="relative w-28 h-28">
<svg className="w-28 h-28 -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--dg-bg-tertiary)" strokeWidth="8" />
<circle
cx="50" cy="50" r="42" fill="none"
stroke={health >= 90 ? '#4ade80' : health >= 70 ? '#facc15' : health >= 50 ? '#fb923c' : '#f87171'}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${(health / 100) * 264} 264`}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${healthColor(health)}`}>{health}</span>
<span className="text-[10px] text-[var(--dg-text-muted)]">{healthLabel(health)}</span>
</div>
</div>
</div>
<div className="space-y-2">
{[
{ label: 'CPU 使用率', value: instance.cpu ?? 0, color: (instance.cpu ?? 0) > 80 ? 'bg-red-400' : (instance.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
{ label: '内存使用率', value: instance.memory ?? 0, color: (instance.memory ?? 0) > 85 ? 'bg-red-400' : (instance.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' },
{ label: '磁盘使用率', value: instance.disk ?? 0, color: (instance.disk ?? 0) > 80 ? 'bg-red-400' : (instance.disk ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
].map(item => (
<div key={item.label}>
<div className="flex justify-between text-xs mb-1">
<span className="text-[var(--dg-text-muted)]">{item.label}</span>
<span className="font-medium">{item.value}%</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${item.color}`} style={{ width: `${item.value}%` }} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Performance Metrics */}
<Card className="dg-card lg:col-span-2">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-400" />
性能指标
</CardTitle>
</CardHeader>
<CardContent className="p-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{[
{ label: '连接数', value: instance.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: 'QPS', value: instance.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '延迟', value: instance.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ label: '运行时间', value: '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' },
].map(m => (
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
<m.icon className={`w-4 h-4 ${m.color}`} />
</div>
<div className="text-lg font-bold">
{typeof m.value === 'number' ? m.value.toLocaleString() : m.value}
<span className="text-xs font-normal text-[var(--dg-text-muted)]">{m.unit}</span>
</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{m.label}</div>
</div>
))}
</div>
{/* Connection Info */}
<Card className="border border-[var(--dg-border)]">
<CardHeader className="border-b border-[var(--dg-border-light)] py-3">
<CardTitle className="text-sm font-semibold">连接信息</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-2">
{[
{ label: '实例名称', value: instance.name },
{ label: '主机地址', value: instance.host },
{ label: '端口', value: instance.port.toString() },
{ label: '用户名', value: instance.username },
{ label: '默认数据库', value: instance.database || '-' },
{ label: '运行环境', value: envLabel[instance.env] || instance.env },
{ label: '版本', value: instance.version || '-' },
{ label: '创建时间', value: new Date(instance.created_at).toLocaleString() },
].map(item => (
<div key={item.label} className="flex justify-between py-1.5 border-b border-[var(--dg-border-light)] last:border-0">
<span className="text-xs text-[var(--dg-text-secondary)]">{item.label}</span>
<span className="text-xs font-medium">{item.value}</span>
</div>
))}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
{instance.description && (
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<CardTitle className="text-sm font-semibold">描述</CardTitle>
</CardHeader>
<CardContent className="p-4">
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.description}</p>
</CardContent>
</Card>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
function MySQLIcon({ className }: { className?: string }) {
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
}
function PostgreSQLIcon({ className }: { className?: string }) {
return <img src="/db-logos/postgres.svg" alt="PostgreSQL" className={className} />;
}
function RedisIcon({ className }: { className?: string }) {
return <img src="/db-logos/redis.svg" alt="Redis" className={className} />;
}
function MongoDBIcon({ className }: { className?: string }) {
return <img src="/db-logos/mongo.svg" alt="MongoDB" className={className} />;
}
/* ------------------------------------------------------------------ */
/* Create Instance Form */
/* ------------------------------------------------------------------ */
const PRESET_MASK_FIELDS = [
'mobile', 'concat_mobile', 'email_alias', 'email', 'password',
'ga_key', 'private_key', 'secret', 'secret_key', 'key_snow',
'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content',
];
const DB_DEFAULT_PORTS: Record<string, number> = {
mysql: 3306, postgres: 5432, redis: 6379, mongo: 27017,
};
const formSteps = [
{ label: '类型选择', icon: Database },
{ label: '基本信息', icon: Server },
{ label: '连接信息', icon: Zap },
{ label: '高级配置', icon: Activity },
];
function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
defaultType: string; onCancel: () => void; onCreated: () => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const [dbType, setDbType] = useState(defaultType);
const [name, setName] = useState('');
const [host, setHost] = useState('');
const [port, setPort] = useState(String(DB_DEFAULT_PORTS[defaultType] || 3306));
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPwd, setShowPwd] = useState(false);
const [database, setDatabase] = useState('');
const [redisDb, setRedisDb] = useState('0');
const [env, setEnv] = useState('development');
const [customEnv, setCustomEnv] = useState('');
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
const [excludedDbs, setExcludedDbs] = useState('');
const [maskFields, setMaskFields] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
const isRedis = dbType === 'redis';
const needUsername = !isRedis;
const needDatabase = !isRedis;
const finalEnv = env === '__custom__' ? customEnv.trim() : env;
const canSubmit = testResult?.success === true;
const handleTypeChange = useCallback((t: string) => {
setDbType(t);
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
setTestResult(null);
if (t === 'redis') { setUsername(''); setDatabase(''); }
}, []);
const handleSubmit = useCallback(async () => {
if (!name) { setError('请填写名称'); return; }
if (!host || !port) { setError('请填写地址和端口'); return; }
if (needUsername && !username) { setError('请填写用户名'); return; }
setSubmitting(true); setError('');
try {
await instanceApi.create({
name, db_type: dbType, host, port: Number(port),
username: username || 'default', password: password || 'default',
database: database || undefined,
env: (finalEnv || 'development') as string,
access_mode: accessMode,
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => {
const parts = line.split(':');
return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' };
}) : undefined,
});
onCreated();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败';
setError(msg);
} finally {
setSubmitting(false);
}
}, [name, host, port, dbType, username, password, database, finalEnv, accessMode, excludedDbs, maskFields, needUsername, onCreated]);
const handleTestConnection = useCallback(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 string, access_mode: accessMode,
});
setTestResult(res as { success: boolean; message: string; version?: string });
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
setTestResult({ success: false, message: msg });
} finally {
setTesting(false);
}
}, [host, port, dbType, username, password, database, finalEnv, accessMode, needUsername, name]);
const validateStep = useCallback((step: number): boolean => {
setError('');
if (step === 1) {
if (!name) { setError('请填写实例名称'); return false; }
}
if (step === 2) {
if (!host || !port) { setError('请填写地址和端口'); return false; }
if (needUsername && !username) { setError('请填写用户名'); return false; }
}
return true;
}, [name, host, port, needUsername, username]);
const goNext = useCallback(() => {
if (validateStep(currentStep)) {
setCurrentStep(s => Math.min(s + 1, formSteps.length - 1));
}
}, [currentStep, validateStep]);
const goPrev = useCallback(() => {
setError('');
setCurrentStep(s => Math.max(s - 1, 0));
}, []);
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 transition-colors';
const labelCls = 'block text-sm font-medium mb-1.5';
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-2xl max-h-[90vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
<div>
<h2 className="text-lg font-semibold">添加数据源</h2>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}</p>
</div>
<button onClick={onCancel} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
<XCircle className="w-5 h-5" />
</button>
</div>
{/* Step Indicator */}
<div className="px-6 pt-4">
<div className="flex items-center gap-2">
{formSteps.map((step, idx) => (
<div key={idx} className="flex items-center gap-2 flex-1">
<button
onClick={() => { if (idx < currentStep) setCurrentStep(idx); }}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all flex-1 justify-center ${
idx === currentStep ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' :
idx < currentStep ? 'bg-green-500/10 text-green-400 border border-green-500/20 cursor-pointer' :
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)]'
}`}
>
<step.icon className="w-3.5 h-3.5" />
{step.label}
</button>
{idx < formSteps.length - 1 && (
<ChevronRight className={`w-3 h-3 flex-shrink-0 ${idx < currentStep ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
)}
</div>
))}
</div>
</div>
<div className="p-6 space-y-4">
{/* Step 0: Type Selection */}
{currentStep === 0 && (
<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-4 gap-3">
{[
{ value: 'mysql', label: 'MySQL', Icon: MySQLIcon },
{ value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon },
{ value: 'redis', label: 'Redis', Icon: RedisIcon },
{ value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon },
].map(t => (
<button
key={t.value}
type="button"
onClick={() => handleTypeChange(t.value)}
className={`flex flex-col items-center gap-2 p-4 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)]'
}`}
>
<t.Icon className="w-10 h-10 object-contain" />
<span className="text-xs font-medium">{t.label}</span>
</button>
))}
</div>
</CardContent>
</Card>
)}
{/* Step 1: Basic Info */}
{currentStep === 1 && (
<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>
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
<input
value={name}
onChange={e => { setName(e.target.value); setTestResult(null); }}
className={inputCls}
placeholder="例如:prod-mysql-只读库"
/>
</div>
<div>
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
<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>
{isRedis && (
<div className="p-3 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
<p className="text-xs text-yellow-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
Redis 实例无需用户名,密码可选
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Step 2: Connection Info */}
{currentStep === 2 && (
<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>
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
<input
value={host}
onChange={e => { setHost(e.target.value); setTestResult(null); }}
className={inputCls}
placeholder="例如:mysql.example.com"
/>
</div>
<div>
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
<input
value={port}
onChange={e => { setPort(e.target.value); setTestResult(null); }}
className={inputCls}
placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)}
/>
</div>
{!isRedis && (
<div>
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
<input
value={username}
onChange={e => { setUsername(e.target.value); setTestResult(null); }}
className={inputCls}
placeholder="root"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{isRedis ? '密码' : '密码'} <span className={isRedis ? 'text-[var(--dg-text-muted)]' : 'text-red-400'}>{isRedis ? '' : '*'}</span></label>
<div className="relative">
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={e => { setPassword(e.target.value); setTestResult(null); }}
className={inputCls + ' pr-10'}
placeholder={isRedis ? '可选,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)] transition-colors"
>
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{needDatabase ? (
<div>
<label className={labelCls}>默认数据库</label>
<input
value={database}
onChange={e => { setDatabase(e.target.value); setTestResult(null); }}
className={inputCls}
placeholder="可选,留空则连接默认库"
/>
</div>
) : (
<div>
<label className={labelCls}>数据库编号</label>
<input
value={redisDb}
onChange={e => setRedisDb(e.target.value)}
className={inputCls}
placeholder="默认 0"
/>
</div>
)}
</div>
{/* Connection Mode */}
<div>
<label className={labelCls}>连接模式</label>
<div className="flex gap-3">
{[
{ value: 'readwrite' as const, label: '读写' },
{ value: 'write' as const, label: '写' },
{ value: 'read' as const, label: '读' },
].map(m => (
<button
key={m.value}
type="button"
onClick={() => setAccessMode(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-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{m.label}
</button>
))}
</div>
</div>
{/* Test Result */}
{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
? <><CheckCircle 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>
</Card>
)}
{/* Step 3: Advanced Config */}
{currentStep === 3 && (
<Card className="dg-card">
<CardContent className="p-5 space-y-4">
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
{!isRedis && (
<div>
<label className={labelCls}>排除的数据库</label>
<input
value={excludedDbs}
onChange={e => setExcludedDbs(e.target.value)}
className={inputCls}
placeholder="以逗号分隔,如:sys, information_schema, mysql"
/>
</div>
)}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className={labelCls}>脱敏字段</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(PRESET_MASK_FIELDS.join(', ')).then(() => {
const btn = document.getElementById('copy-mask-btn');
if (btn) {
btn.textContent = '已复制';
setTimeout(() => { if (btn) btn.textContent = '复制全部'; }, 1500);
}
});
}}
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={() => {
const newFields = PRESET_MASK_FIELDS.map(f => f + ':mask').join('\n');
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>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_MASK_FIELDS.map(f => {
const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f);
return (
<button
key={f}
type="button"
onClick={() => {
if (isSelected) {
setMaskFields(maskFields.split('\n').filter(line => {
return !line.split(':')[0]?.trim()?.includes(f);
}).join('\n'));
} else {
setMaskFields(maskFields ? maskFields + '\n' + f + ':mask' : f + ':mask');
}
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isSelected ? 'bg-blue-500/20 text-blue-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{f}
</button>
);
})}
</div>
<textarea
value={maskFields}
onChange={e => setMaskFields(e.target.value)}
className={inputCls + ' min-h-[80px] resize-y font-mono text-xs'}
placeholder={'每行一个,格式:字段名:规则\n点击上方标签快速添加\n例如:\nmobile:mask\nemail:mask\npassword:hide'}
/>
</div>
</CardContent>
</Card>
)}
{error && <div className="text-sm text-red-400 flex items-center gap-2"><AlertTriangle className="w-4 h-4" />{error}</div>}
{/* Navigation Buttons */}
<div className="flex items-center justify-between pt-2">
<button
onClick={handleTestConnection}
disabled={testing || !host || !port}
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 transition-all"
>
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{testing ? '测试中...' : '测试连接'}
</button>
<div className="flex gap-3">
{currentStep > 0 && (
<button onClick={goPrev} 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)] transition-colors">
上一步
</button>
)}
{currentStep < formSteps.length - 1 ? (
<button onClick={goNext} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white transition-colors">
下一步
</button>
) : (
<>
<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)] transition-colors">
取消
</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>
);
}