Files
opsxsql/frontend/src/pages/RolesPage.tsx
T
logan 58eb78c987 merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1):
- mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5)
- redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5)
- mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能)
- postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7)

后端合并 (Step 2):
- audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则)
- audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射
- audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb)

前端优化 (Step 4) — 16页面全覆盖:
- InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage
- UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard
- InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage
- useAuth.tsx, SqlEditor.tsx

后端测试 895/895 通过, 前端 tsc --noEmit 通过
2026-05-23 16:46:13 +08:00

585 lines
26 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react';
import {
Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save,
Search, X, Copy, GitCompare, Users, Lock, BarChart3, XCircle,
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { roles as mockRoles, permissionTreeData, users as mockUsers } from '@/lib/mock';
import type { Role } from '@/types';
/* ───────── Types ───────── */
interface PermissionChange {
id: string;
label: string;
action: 'added' | 'removed';
}
interface PermissionPreset {
key: string;
name: string;
description: string;
icon: React.ElementType;
permissions: string[];
}
/* ───────── Constants ───────── */
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
const PERMISSION_PRESETS: PermissionPreset[] = [
{
key: 'dba',
name: 'DBA 模板',
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
icon: Database,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'developer',
name: '开发模板',
description: '开发人员:工单提交、查询执行、数据导出',
icon: Code,
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
},
{
key: 'auditor',
name: '审计模板',
description: '审计人员:查看查询日志、审计规则、审批流程',
icon: Eye,
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'admin',
name: '管理员模板',
description: '超级管理员:系统全部权限',
icon: Crown,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
},
];
/* ───────── Helpers ───────── */
function collectAllIds(nodes: typeof permissionTreeData): string[] {
const ids: string[] = [];
const walk = (list: typeof permissionTreeData) => {
list.forEach(n => {
ids.push(n.id);
if (n.children) walk(n.children);
});
};
walk(nodes);
return ids;
}
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
if (!query.trim()) return nodes;
const lower = query.toLowerCase();
return nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? searchTree(node.children, query) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
}
function computeChanges(original: Set<string>, current: Set<string>): PermissionChange[] {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const changes: PermissionChange[] = [];
allIds.forEach(id => {
if (current.has(id) && !original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!current.has(id) && original.has(id)) {
changes.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return changes;
}
/* ───────── Main Component ───────── */
export default function RolesPage() {
const [selectedRole, setSelectedRole] = useState<Role | null>(mockRoles[0]);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
const [searchQuery, setSearchQuery] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [showCloneModal, setShowCloneModal] = useState<Role | null>(null);
const [showStats, setShowStats] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const hasChanges = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
}, [checkedKeys, originalKeys]);
const changes = useMemo(() => computeChanges(originalKeys, checkedKeys), [originalKeys, checkedKeys]);
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
const handleRoleSelect = useCallback((role: Role) => {
setSelectedRole(role);
const newSet = new Set(role.permissions);
setCheckedKeys(newSet);
setOriginalKeys(newSet);
setShowDiff(false);
}, []);
const togglePermission = useCallback((id: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleChildren = useCallback((node: typeof permissionTreeData[0], checked: boolean) => {
setCheckedKeys(prev => {
const next = new Set(prev);
const toggleNode = (n: typeof node) => {
if (checked) next.add(n.id);
else next.delete(n.id);
n.children?.forEach(toggleNode);
};
toggleNode(node);
return next;
});
}, []);
const applyPreset = (preset: PermissionPreset) => {
setCheckedKeys(new Set(preset.permissions));
setShowPresets(false);
setToast({ message: `已应用「${preset.name}」`, type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleSave = () => {
if (!selectedRole) return;
setOriginalKeys(new Set(checkedKeys));
setToast({ message: '权限已保存', type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleDiscard = () => {
setCheckedKeys(new Set(originalKeys));
setShowDiff(false);
setToast({ message: '已撤销未保存的更改', type: 'error' });
setTimeout(() => setToast(null), 2000);
};
// Role statistics
const roleStats = useMemo(() => {
return mockRoles.map(role => {
const roleUsers = mockUsers.filter(u => u.role === role.id);
return {
role,
userCount: roleUsers.length,
permissionCount: role.permissions.length,
onlineUsers: roleUsers.filter(u => u.status === 'active').length,
};
});
}, []);
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">角色权限管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowStats(!showStats)}
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)] transition-all"
>
<BarChart3 className="w-4 h-4" /> 统计
</button>
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Shield className="w-4 h-4" /> 新建角色
</button>
</div>
</div>
{/* Stats Panel */}
{showStats && (
<div className="grid grid-cols-4 gap-4">
{roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => {
const Icon = iconMap[role.icon] || Eye;
return (
<Card key={role.id} className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${role.iconClass}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-semibold">{role.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{role.userCount} 用户</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-blue-400">{userCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">用户</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-purple-400">{permissionCount}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">权限</div>
</div>
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-lg font-bold text-green-400">{onlineUsers}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">在线</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Role Cards */}
<div className="grid grid-cols-4 gap-4">
{mockRoles.map(role => {
const Icon = iconMap[role.icon] || Eye;
return (
<div
key={role.id}
onClick={() => handleRoleSelect(role)}
className={`dg-card p-5 cursor-pointer transition-all ${selectedRole?.id === role.id ? 'border-blue-500/50 bg-blue-500/5' : 'dg-card-hover'}`}
>
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${role.iconClass}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold truncate">{role.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] truncate">{role.description}</div>
</div>
</div>
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
<div className="text-xs text-[var(--dg-text-secondary)]">
<Users className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
<Lock className="w-3 h-3 inline mr-1" />
<span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限
</div>
</div>
{/* Clone button */}
<button
onClick={(e) => { e.stopPropagation(); setShowCloneModal(role); }}
className="mt-3 flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
>
<Copy className="w-3 h-3" /> 克隆角色
</button>
</div>
);
})}
</div>
{/* Permission Config */}
{selectedRole && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
{hasChanges && (
<span className="px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 text-xs font-medium">
有未保存的更改 ({changes.length})
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 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-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
)}
</div>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<GitCompare className="w-4 h-4" /> 变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 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"
>
<Save className="w-4 h-4" /> 保存权限
</button>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 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"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
</div>
)}
</div>
</div>
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<GitCompare className="w-4 h-4 text-[var(--dg-text-muted)]" />
变更详情
</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Clone Modal */}
{showCloneModal && (
<CloneRoleModal
sourceRole={showCloneModal}
onClose={() => setShowCloneModal(null)}
/>
)}
</div>
);
}
/* ───────── Permission Node Component ───────── */
function PermissionNodeComponent({
node, checkedKeys, onToggle, onToggleChildren, depth
}: {
node: typeof permissionTreeData[0];
checkedKeys: Set<string>;
onToggle: (id: string) => void;
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
depth: number;
}) {
const [expanded, setExpanded] = useState(true);
const isChecked = checkedKeys.has(node.id);
const hasChildren = node.children && node.children.length > 0;
const allChildrenChecked = hasChildren && node.children!.every(c => checkedKeys.has(c.id));
const someChildrenChecked = hasChildren && node.children!.some(c => checkedKeys.has(c.id));
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 20 + 12}px` }}
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
>
{hasChildren && (
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
)}
{!hasChildren && <div className="w-4" />}
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
isChecked || allChildrenChecked
? 'bg-blue-500 border-blue-500'
: someChildrenChecked
? 'bg-blue-500/50 border-blue-500'
: 'border-[var(--dg-text-muted)]'
}`}
onClick={e => {
e.stopPropagation();
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
else onToggle(node.id);
}}
>
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-sm select-none">{node.label}</span>
</div>
{hasChildren && expanded && (
<div>
{node.children!.map(child => (
<PermissionNodeComponent
key={child.id}
node={child}
checkedKeys={checkedKeys}
onToggle={onToggle}
onToggleChildren={onToggleChildren}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
/* ───────── Clone Role Modal ───────── */
function CloneRoleModal({ sourceRole, onClose }: { sourceRole: Role; onClose: () => void }) {
const [name, setName] = useState(`${sourceRole.name} (副本)`);
const [description, setDescription] = useState(sourceRole.description);
const [submitting, setSubmitting] = useState(false);
const handleClone = async () => {
setSubmitting(true);
try {
// In real implementation, call API
await new Promise(r => setTimeout(r, 500));
onClose();
} catch {
// handle error
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><Copy className="w-5 h-5 text-blue-400" /> 克隆角色</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-lg mb-4">
<p className="text-sm text-[var(--dg-text-secondary)]">基于 <strong className="text-[var(--dg-text-primary)]">{sourceRole.name}</strong> 创建副本,包含 {sourceRole.permissions.length} 个权限</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">角色名称 *</label>
<input value={name} onChange={e => setName(e.target.value)} className="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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} className="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 resize-none" rows={2} />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} 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={handleClone} disabled={submitting || !name.trim()} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
{submitting ? '克隆中...' : '确认克隆'}
</button>
</div>
</div>
</div>
</div>
);
}