feat: 权限组简化 + 工单撤销按钮
后端: - InstanceGroup 新增 access_type (readonly/datachange/ddl) 和 allowed_databases 字段 - 创建 role_permissions.py 角色权限服务 (admin/dba/auditor/developer) - SQLite ALTER TABLE 迁移脚本 前端: - InstanceGroupPage 全面重写: 3种权限类型卡片(只读/数据变更/DDL)替代20+开关 - 新增/编辑弹窗: 权限类型选择 + 限定数据库输入 - 左侧列表显示权限类型标签 - 菜单标签: '权限管理' → '权限组' - 工单列表 + 工单详情: 新增撤销按钮 (pending/reviewing 状态可撤销) - API 类型定义: 新增 access_type/allowed_databases
This commit is contained in:
1 parent
b80d383b28
commit
8161bcf5b5
4 files changed
+276
-516
No files matched your search
@@ -6,6 +6,8 @@ export interface InstanceGroup {
|
|||||||
description?: string;
|
description?: string;
|
||||||
db_type: string;
|
db_type: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
access_type?: 'readonly' | 'datachange' | 'ddl';
|
||||||
|
allowed_databases?: string[];
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const menuSections = [
|
|||||||
export const menuItems = [
|
export const menuItems = [
|
||||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
||||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||||
{ id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
{ id: 'instance-groups', label: '权限组', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'] },
|
||||||
{ id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
{ id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
||||||
Shield, Grid3x3, Clock, CheckCircle2,
|
Shield, Eye, PenLine, Hammer, Database,
|
||||||
Database, HardDrive, Activity,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
@@ -10,74 +9,31 @@ import { instanceApi } from '@/api/instance';
|
|||||||
import { userApi } from '@/api/user';
|
import { userApi } from '@/api/user';
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
|
||||||
|
|
||||||
interface PermissionCategory {
|
|
||||||
label: string;
|
|
||||||
items: { code: string; name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceQuota {
|
|
||||||
max_connections: number;
|
|
||||||
max_qps: number;
|
|
||||||
max_storage_gb: number;
|
|
||||||
max_queries_per_day: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChangeLogEntry {
|
|
||||||
id: string;
|
|
||||||
user: string;
|
|
||||||
action: string;
|
|
||||||
target: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────── Constants ───────── */
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
const ACCESS_TYPES = [
|
||||||
{
|
{
|
||||||
label: '查询权限',
|
value: 'readonly',
|
||||||
items: [
|
label: '只读查询',
|
||||||
{ code: 'query:select', name: 'SELECT' },
|
desc: 'SELECT',
|
||||||
{ code: 'query:insert', name: 'INSERT' },
|
color: 'bg-emerald-500/15 text-emerald-400',
|
||||||
{ code: 'query:update', name: 'UPDATE' },
|
icon: Eye,
|
||||||
{ code: 'query:delete', name: 'DELETE' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DDL 权限',
|
value: 'datachange',
|
||||||
items: [
|
label: '数据变更',
|
||||||
{ code: 'ddl:create_table', name: '创建表' },
|
desc: 'INSERT / UPDATE / DELETE',
|
||||||
{ code: 'ddl:alter_table', name: '修改表' },
|
color: 'bg-amber-500/15 text-amber-400',
|
||||||
{ code: 'ddl:drop_table', name: '删除表' },
|
icon: PenLine,
|
||||||
{ code: 'ddl:create_index', name: '创建索引' },
|
|
||||||
{ code: 'ddl:drop_index', name: '删除索引' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '工单权限',
|
value: 'ddl',
|
||||||
items: [
|
label: 'DDL 变更',
|
||||||
{ code: 'ticket:create', name: '创建工单' },
|
desc: 'CREATE / ALTER / DROP / INDEX',
|
||||||
{ code: 'ticket:approve', name: '审批工单' },
|
color: 'bg-red-500/15 text-red-400',
|
||||||
{ code: 'ticket:reject', name: '拒绝工单' },
|
icon: Hammer,
|
||||||
{ code: 'ticket:execute', name: '执行工单' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
] as const;
|
||||||
label: '数据操作',
|
|
||||||
items: [
|
|
||||||
{ code: 'export:data', name: '导出数据' },
|
|
||||||
{ code: 'import:data', name: '导入数据' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '管理权限',
|
|
||||||
items: [
|
|
||||||
{ code: 'instance:connect', name: '连接实例' },
|
|
||||||
{ code: 'instance:kill_process', name: '终止进程' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const DB_TYPE_OPTIONS = [
|
const DB_TYPE_OPTIONS = [
|
||||||
{ value: 'all', label: '全部类型' },
|
{ value: 'all', label: '全部类型' },
|
||||||
@@ -95,13 +51,6 @@ const DB_TYPE_COLORS: Record<string, string> = {
|
|||||||
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
|
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_QUOTA: ResourceQuota = {
|
|
||||||
max_connections: 100,
|
|
||||||
max_qps: 1000,
|
|
||||||
max_storage_gb: 50,
|
|
||||||
max_queries_per_day: 10000,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ───────── Main Component ───────── */
|
/* ───────── Main Component ───────── */
|
||||||
|
|
||||||
export default function InstanceGroupPage() {
|
export default function InstanceGroupPage() {
|
||||||
@@ -113,19 +62,16 @@ export default function InstanceGroupPage() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showInstanceModal, setShowInstanceModal] = useState(false);
|
const [showInstanceModal, setShowInstanceModal] = useState(false);
|
||||||
const [showUserModal, setShowUserModal] = useState(false);
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'matrix' | 'quota' | 'logs'>('list');
|
|
||||||
const canManage = useActionPermission('instance-group.manage');
|
const canManage = useActionPermission('instance-group.manage');
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
const [formDesc, setFormDesc] = useState('');
|
const [formDesc, setFormDesc] = useState('');
|
||||||
const [formDbType, setFormDbType] = useState('all');
|
const [formDbType, setFormDbType] = useState('all');
|
||||||
const [formPermissions, setFormPermissions] = useState<string[]>([]);
|
const [formAccessType, setFormAccessType] = useState('readonly');
|
||||||
|
const [formAllowedDbs, setFormAllowedDbs] = useState('');
|
||||||
const [formIsActive, setFormIsActive] = useState(true);
|
const [formIsActive, setFormIsActive] = useState(true);
|
||||||
|
|
||||||
// Quota state
|
|
||||||
const [quota, setQuota] = useState<ResourceQuota>(DEFAULT_QUOTA);
|
|
||||||
|
|
||||||
// Related data
|
// Related data
|
||||||
const [allInstances, setAllInstances] = useState<any[]>([]);
|
const [allInstances, setAllInstances] = useState<any[]>([]);
|
||||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
@@ -149,7 +95,6 @@ export default function InstanceGroupPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
||||||
|
|
||||||
// Load related data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showInstanceModal) {
|
if (showInstanceModal) {
|
||||||
instanceApi.list().then(setAllInstances).catch(() => {});
|
instanceApi.list().then(setAllInstances).catch(() => {});
|
||||||
@@ -174,18 +119,13 @@ export default function InstanceGroupPage() {
|
|||||||
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
|
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
|
||||||
}, [allUsers, userSearch]);
|
}, [allUsers, userSearch]);
|
||||||
|
|
||||||
const changeLogs = useMemo(() => {
|
|
||||||
if (!selectedId) return [];
|
|
||||||
return [];
|
|
||||||
}, [selectedId]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormName('');
|
setFormName('');
|
||||||
setFormDesc('');
|
setFormDesc('');
|
||||||
setFormDbType('all');
|
setFormDbType('all');
|
||||||
setFormPermissions([]);
|
setFormAccessType('readonly');
|
||||||
|
setFormAllowedDbs('');
|
||||||
setFormIsActive(true);
|
setFormIsActive(true);
|
||||||
setQuota(DEFAULT_QUOTA);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -194,9 +134,10 @@ export default function InstanceGroupPage() {
|
|||||||
name: formName,
|
name: formName,
|
||||||
description: formDesc || undefined,
|
description: formDesc || undefined,
|
||||||
db_type: formDbType,
|
db_type: formDbType,
|
||||||
permissions: formPermissions.length > 0 ? formPermissions : undefined,
|
access_type: formAccessType,
|
||||||
|
allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
is_active: formIsActive,
|
is_active: formIsActive,
|
||||||
});
|
} as any);
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchGroups();
|
fetchGroups();
|
||||||
@@ -208,9 +149,10 @@ export default function InstanceGroupPage() {
|
|||||||
name: formName,
|
name: formName,
|
||||||
description: formDesc || undefined,
|
description: formDesc || undefined,
|
||||||
db_type: formDbType,
|
db_type: formDbType,
|
||||||
permissions: formPermissions.length > 0 ? formPermissions : undefined,
|
access_type: formAccessType,
|
||||||
|
allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
is_active: formIsActive,
|
is_active: formIsActive,
|
||||||
});
|
} as any);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchGroups();
|
fetchGroups();
|
||||||
@@ -227,7 +169,8 @@ export default function InstanceGroupPage() {
|
|||||||
setFormName(group.name);
|
setFormName(group.name);
|
||||||
setFormDesc(group.description || '');
|
setFormDesc(group.description || '');
|
||||||
setFormDbType(group.db_type);
|
setFormDbType(group.db_type);
|
||||||
setFormPermissions(group.permissions || []);
|
setFormAccessType((group as any).access_type || 'readonly');
|
||||||
|
setFormAllowedDbs(((group as any).allowed_databases || []).join(', '));
|
||||||
setFormIsActive(group.is_active);
|
setFormIsActive(group.is_active);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
@@ -248,32 +191,193 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (code: string) => {
|
const getAccessTypeConfig = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0];
|
||||||
setFormPermissions(prev =>
|
|
||||||
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
|
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 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">按访问类型分组,关联数据源和用户</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
|
||||||
|
<Plus className="w-4 h-4" /> 新建权限组
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{ACCESS_TYPES.map(at => {
|
||||||
|
const count = groups.filter(g => (g as any).access_type === at.value).length;
|
||||||
|
const Icon = at.icon;
|
||||||
|
return (
|
||||||
|
<Card key={at.value} className="dg-card">
|
||||||
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg ${at.color} flex items-center justify-center`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">{count}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{at.label}权限组</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
const toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
|
<div className="flex gap-5">
|
||||||
const codes = items.map(i => i.code);
|
{/* Left list */}
|
||||||
if (checked) {
|
<div className="w-80 flex-shrink-0 space-y-3">
|
||||||
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
|
<div className="relative">
|
||||||
} else {
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
setFormPermissions(prev => prev.filter(p => !codes.includes(p)));
|
<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" placeholder="搜索权限组" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
}
|
</div>
|
||||||
};
|
<div className="space-y-2 max-h-[calc(100vh-380px)] overflow-y-auto">
|
||||||
|
{filteredGroups.length === 0 && (
|
||||||
|
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>
|
||||||
|
)}
|
||||||
|
{filteredGroups.map(group => {
|
||||||
|
const atConfig = getAccessTypeConfig((group as any).access_type || 'readonly');
|
||||||
|
const ATIcon = atConfig.icon;
|
||||||
|
return (
|
||||||
|
<div key={group.id} onClick={() => setSelectedId(group.id)} className={`p-3.5 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<ATIcon className={`w-4 h-4 ${atConfig.color.includes('emerald') ? 'text-emerald-400' : atConfig.color.includes('amber') ? 'text-amber-400' : 'text-red-400'}`} />
|
||||||
|
<span className="font-medium text-sm">{group.name}</span>
|
||||||
|
</div>
|
||||||
|
{!group.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">禁用</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] ${atConfig.color}`}>{atConfig.label}</span>
|
||||||
|
<span className="flex items-center gap-1"><Server className="w-3 h-3" />{group.instance_count || 0}</span>
|
||||||
|
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{group.user_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Permission matrix data
|
{/* Right detail */}
|
||||||
const permissionMatrix = useMemo(() => {
|
<div className="flex-1 min-w-0">
|
||||||
if (!selectedGroup) return [];
|
{selectedGroup ? (() => {
|
||||||
const allPerms = PERMISSION_CATEGORIES.flatMap(cat => cat.items);
|
const sg = selectedGroup;
|
||||||
return allPerms.map(p => ({
|
const atConfig = getAccessTypeConfig((sg as any).access_type || 'readonly');
|
||||||
...p,
|
const ATIcon = atConfig.icon;
|
||||||
enabled: (selectedGroup.permissions || []).includes(p.code),
|
const allowedDbs: string[] = (sg as any).allowed_databases || [];
|
||||||
}));
|
return (
|
||||||
}, [selectedGroup]);
|
<div className="space-y-4">
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ATIcon className={`w-5 h-5 ${atConfig.color.includes('emerald') ? 'text-emerald-400' : atConfig.color.includes('amber') ? 'text-amber-400' : 'text-red-400'}`} />
|
||||||
|
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded ${atConfig.color}`}>{atConfig.label}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
||||||
|
{!sg.is_active && <span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>}
|
||||||
|
</div>
|
||||||
|
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
|
||||||
|
允许操作: <code className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] rounded text-[var(--dg-text-primary)]">{atConfig.desc}</code>
|
||||||
|
</div>
|
||||||
|
{allowedDbs.length > 0 && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1.5">
|
||||||
|
限定数据库: {allowedDbs.map(d => <span key={d} className="inline-block px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] rounded mr-1">{d}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
const renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
|
{/* Related Instances */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联数据源 ({sg.instances?.length || 0})</h3>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sg.instances && sg.instances.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{sg.instances.map(inst => (
|
||||||
|
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||||
|
<span className="text-sm truncate">{inst.name}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] flex-shrink-0">{inst.host}:{inst.port}</span>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors flex-shrink-0"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联数据源</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Related Users */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sg.users && sg.users.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{sg.users.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||||
|
<span className="text-sm">{u.display_name || u.username}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
||||||
|
<ChevronRight className="w-8 h-8 mb-2 rotate-90 opacity-50" />
|
||||||
|
<p className="text-sm">选择左侧权限组查看详情</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
{(showCreateModal || showEditModal) && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">{showCreateModal ? '新建权限组' : '编辑权限组'}</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
|
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
|
||||||
@@ -298,422 +402,49 @@ export default function InstanceGroupPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Access Type — 3 cards */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">权限配置</label>
|
<label className="block text-sm font-medium mb-2">权限类型</label>
|
||||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{PERMISSION_CATEGORIES.map(cat => {
|
{ACCESS_TYPES.map(at => {
|
||||||
const allChecked = cat.items.every(i => formPermissions.includes(i.code));
|
const Icon = at.icon;
|
||||||
return (
|
return (
|
||||||
<div key={cat.label}>
|
<button
|
||||||
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
|
key={at.value}
|
||||||
<input type="checkbox" checked={allChecked} onChange={e => toggleCategoryAll(cat.items, e.target.checked)} className="rounded border-[var(--dg-border)]" />
|
type="button"
|
||||||
{cat.label}
|
onClick={() => setFormAccessType(at.value)}
|
||||||
</label>
|
className={`p-3 rounded-lg text-center transition-colors ${formAccessType === at.value ? at.color + ' ring-1 ring-current' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}
|
||||||
<div className="flex flex-wrap gap-1.5 ml-5">
|
>
|
||||||
{cat.items.map(item => (
|
<Icon className="w-5 h-5 mx-auto mb-1" />
|
||||||
<label key={item.code} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs cursor-pointer transition-colors ${formPermissions.includes(item.code) ? 'bg-blue-500/15 text-blue-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
|
<div className="text-xs font-medium">{at.label}</div>
|
||||||
<input type="checkbox" checked={formPermissions.includes(item.code)} onChange={() => togglePermission(item.code)} className="hidden" />
|
<div className="text-[10px] mt-0.5 opacity-70">{at.desc}</div>
|
||||||
{item.name}
|
</button>
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Allowed databases */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5">限定数据库 <span className="text-[var(--dg-text-muted)] font-normal">(逗号分隔,留空=全部库)</span></label>
|
||||||
|
<input 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" value={formAllowedDbs} onChange={e => setFormAllowedDbs(e.target.value)} placeholder="shop_db, order_db, user_db" />
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button onClick={() => { setShowCreateModal(false); setShowEditModal(false); resetForm(); }} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
<button onClick={() => { setShowCreateModal(false); setShowEditModal(false); resetForm(); }} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||||
<button onClick={onSubmit} disabled={!formName.trim()} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">{submitLabel}</button>
|
<button onClick={showCreateModal ? handleCreate : handleEdit} disabled={!formName.trim()} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">{showCreateModal ? '创建' : '保存'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<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">管理实例访问权限,按组分配用户和操作权限</p>
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
|
|
||||||
<Plus className="w-4 h-4" /> 新建权限组
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Server },
|
|
||||||
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10', icon: CheckCircle2 },
|
|
||||||
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10', icon: Database },
|
|
||||||
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10', icon: Users },
|
|
||||||
].map((s, i) => {
|
|
||||||
const Icon = s.icon;
|
|
||||||
return (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
|
||||||
<Icon className={`w-5 h-5 ${s.color}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold">{s.value}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-5">
|
|
||||||
{/* Left list */}
|
|
||||||
<div className="w-80 flex-shrink-0 space-y-3">
|
|
||||||
<div className="relative">
|
|
||||||
<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" placeholder="搜索权限组" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[calc(100vh-380px)] overflow-y-auto">
|
|
||||||
{filteredGroups.length === 0 && (
|
|
||||||
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>
|
|
||||||
)}
|
|
||||||
{filteredGroups.map(group => (
|
|
||||||
<div key={group.id} onClick={() => setSelectedId(group.id)} className={`p-3.5 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="font-medium text-sm">{group.name}</span>
|
|
||||||
<span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[group.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === group.db_type)?.label || group.db_type}</span>
|
|
||||||
</div>
|
|
||||||
{!group.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">禁用</span>}
|
|
||||||
</div>
|
|
||||||
{group.description && <p className="text-xs text-[var(--dg-text-muted)] mt-1 line-clamp-1">{group.description}</p>}
|
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span className="flex items-center gap-1"><Server className="w-3 h-3" />{group.instance_count || 0} 实例</span>
|
|
||||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{group.user_count || 0} 用户</span>
|
|
||||||
<span>{(group.permissions || []).length} 权限</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right detail */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{selectedGroup ? (() => {
|
|
||||||
const sg = selectedGroup!;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Info Card */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
|
|
||||||
</div>
|
|
||||||
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* View Tabs */}
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
|
||||||
{[
|
|
||||||
{ key: 'list' as const, label: '权限列表', icon: Shield },
|
|
||||||
{ key: 'matrix' as const, label: '权限矩阵', icon: Grid3x3 },
|
|
||||||
{ key: 'quota' as const, label: '资源配额', icon: HardDrive },
|
|
||||||
{ key: 'logs' as const, label: '变更日志', icon: Clock },
|
|
||||||
].map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setViewMode(tab.key)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
||||||
viewMode === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3.5 h-3.5" />
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View: Permission List */}
|
|
||||||
{viewMode === 'list' && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
{/* Permission tags */}
|
|
||||||
{sg.permissions && sg.permissions.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{sg.permissions.map(p => {
|
|
||||||
const parts = p.split(':');
|
|
||||||
return (
|
|
||||||
<span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md flex items-center gap-1">
|
|
||||||
<Shield className="w-3 h-3" />
|
|
||||||
{parts[0]} · {parts[1]}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!sg.permissions || sg.permissions.length === 0) && (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无权限配置</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View: Permission Matrix */}
|
|
||||||
{viewMode === 'matrix' && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)]">
|
|
||||||
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限类别</th>
|
|
||||||
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限项</th>
|
|
||||||
<th className="text-center px-3 py-2 font-medium text-[var(--dg-text-muted)] w-20">状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{PERMISSION_CATEGORIES.map(cat => (
|
|
||||||
cat.items.map((item, itemIdx) => (
|
|
||||||
<tr key={item.code} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
{itemIdx === 0 && (
|
|
||||||
<td rowSpan={cat.items.length} className="px-3 py-2 font-medium text-xs text-[var(--dg-text-secondary)] align-top">
|
|
||||||
{cat.label}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-3 py-2">{item.name}</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
{permissionMatrix.find(p => p.code === item.code)?.enabled ? (
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-400 inline" />
|
|
||||||
) : (
|
|
||||||
<X className="w-4 h-4 text-[var(--dg-text-muted)] inline" />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View: Resource Quota */}
|
|
||||||
{viewMode === 'quota' && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '最大连接数', key: 'max_connections' as const, icon: Users, unit: '' },
|
|
||||||
{ label: '最大 QPS', key: 'max_qps' as const, icon: Activity, unit: '/s' },
|
|
||||||
{ label: '最大存储', key: 'max_storage_gb' as const, icon: HardDrive, unit: 'GB' },
|
|
||||||
{ label: '每日最大查询数', key: 'max_queries_per_day' as const, icon: Database, unit: '' },
|
|
||||||
].map(item => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const value = quota[item.key];
|
|
||||||
const percentage = Math.min(100, (value / (DEFAULT_QUOTA[item.key] * 2)) * 100);
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<span className="text-sm">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold">{value.toLocaleString()}{item.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 bg-[var(--dg-bg-secondary)] rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-300 bg-blue-500"
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<h4 className="text-sm font-medium mb-3">编辑配额</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{([
|
|
||||||
{ label: '最大连接数', key: 'max_connections' as const },
|
|
||||||
{ label: '最大 QPS', key: 'max_qps' as const },
|
|
||||||
{ label: '最大存储 (GB)', key: 'max_storage_gb' as const },
|
|
||||||
{ label: '每日最大查询数', key: 'max_queries_per_day' as const },
|
|
||||||
]).map(item => (
|
|
||||||
<div key={item.key}>
|
|
||||||
<label className="block text-xs font-medium mb-1">{item.label}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={quota[item.key]}
|
|
||||||
onChange={e => setQuota(p => ({ ...p, [item.key]: Number(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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View: Change Logs */}
|
|
||||||
{viewMode === 'logs' && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
|
||||||
{changeLogs.map(log => (
|
|
||||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
||||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm">{log.action}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{log.target !== '-' ? log.target : ''}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
|
|
||||||
{log.user.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
|
||||||
{new Date(log.created_at).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{changeLogs.length === 0 && (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无变更记录</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Related Instances */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sg.instances && sg.instances.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{sg.instances.map(inst => (
|
|
||||||
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
|
||||||
<span className="text-sm">{inst.name}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
|
||||||
<Server className="w-8 h-8 mb-2 opacity-30" />
|
|
||||||
暂未关联实例
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Related Users */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
|
||||||
{canManage && (
|
|
||||||
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sg.users && sg.users.length > 0 ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{sg.users.map(u => (
|
|
||||||
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
|
||||||
<span className="text-sm">{u.display_name || u.username}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
|
||||||
<Users className="w-8 h-8 mb-2 opacity-30" />
|
|
||||||
暂未关联用户
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
|
||||||
<ChevronRight className="w-8 h-8 mb-2 rotate-90 opacity-50" />
|
|
||||||
<p className="text-sm">选择左侧权限组查看详情</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">新建权限组</h3>
|
|
||||||
{renderGroupForm(handleCreate, '创建')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Instance Modal */}
|
||||||
{showEditModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">编辑权限组</h3>
|
|
||||||
{renderGroupForm(handleEdit, '保存')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Instance Modal with search */}
|
|
||||||
{showInstanceModal && (
|
{showInstanceModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
|
<h3 className="text-lg font-semibold mb-4">关联数据源</h3>
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<input
|
<input value={instanceSearch} onChange={e => setInstanceSearch(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 focus:border-blue-400 focus:outline-none" />
|
||||||
value={instanceSearch}
|
|
||||||
onChange={e => setInstanceSearch(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 focus:border-blue-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||||
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
||||||
@@ -736,19 +467,14 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Modal with search */}
|
{/* User Modal */}
|
||||||
{showUserModal && (
|
{showUserModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
|
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
<input
|
<input value={userSearch} onChange={e => setUserSearch(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 focus:border-blue-400 focus:outline-none" />
|
||||||
value={userSearch}
|
|
||||||
onChange={e => setUserSearch(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 focus:border-blue-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||||
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
||||||
|
|||||||
@@ -581,6 +581,22 @@ export default function TicketsPage() {
|
|||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
查看
|
查看
|
||||||
</button>
|
</button>
|
||||||
|
{(ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('确定撤销该工单?')) return;
|
||||||
|
try {
|
||||||
|
await ticketApi.cancel(ticket.id);
|
||||||
|
fetchTickets();
|
||||||
|
fetchStats();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-amber-400 hover:text-amber-300 text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Ban className="w-3.5 h-3.5" />
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -886,6 +902,22 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
|||||||
执行
|
执行
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('确定撤销该工单?')) return;
|
||||||
|
try {
|
||||||
|
await ticketApi.cancel(ticket.id);
|
||||||
|
onActionDone();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-amber-500/15 border border-amber-500/30 rounded-lg text-sm font-medium text-amber-400 hover:bg-amber-500/25 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user