322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Plus, Shield, Edit, Eye, Trash2 } from 'lucide-react';
|
|
import { userApi } from '@/services/api';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import type { Role } from '@/types';
|
|
import {
|
|
RoleFormDialog,
|
|
type RoleFormData,
|
|
type PermissionOption,
|
|
} from './RoleFormDialog';
|
|
|
|
interface SystemRolesTabProps {
|
|
selectedRole: Role | null;
|
|
setSelectedRole: (role: Role | null) => void;
|
|
}
|
|
|
|
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
|
|
function normalizeRole(raw: any): Role {
|
|
const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : [];
|
|
return {
|
|
id: String(raw.id ?? ''),
|
|
name: raw.name ?? '',
|
|
code: raw.code ?? '',
|
|
description: raw.description ?? '',
|
|
// 后端可能返回权限对象数组或字符串数组,统一映射为编码字符串数组
|
|
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
|
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
|
// 保留原始权限 ID 供编辑使用
|
|
_permissionIds: Array.isArray(raw.permissions)
|
|
? raw.permissions.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)).filter(Boolean)
|
|
: [],
|
|
} as Role & { _permissionIds: number[] };
|
|
}
|
|
|
|
const emptyForm: RoleFormData = {
|
|
name: '',
|
|
code: '',
|
|
description: '',
|
|
permission_ids: [],
|
|
};
|
|
|
|
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
|
|
const confirm = useConfirm();
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [permissions, setPermissions] = useState<PermissionOption[]>([]);
|
|
|
|
// 新建 / 编辑弹窗
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingRole, setEditingRole] = useState<any | null>(null);
|
|
const [form, setForm] = useState<RoleFormData>({ ...emptyForm });
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const loadRoles = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const resp = await userApi.getRoles();
|
|
const list = resp?.list ?? [];
|
|
setRoles(list.map(normalizeRole));
|
|
} catch (err) {
|
|
setRoles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadPermissions = useCallback(async () => {
|
|
try {
|
|
const resp = await userApi.getPermissions();
|
|
const list = resp?.list ?? [];
|
|
setPermissions(
|
|
list.map((p: any) => ({
|
|
id: Number(p.id),
|
|
name: p.name ?? p.code ?? '',
|
|
code: p.code ?? '',
|
|
type: p.type,
|
|
}))
|
|
);
|
|
} catch {
|
|
setPermissions([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadRoles();
|
|
loadPermissions();
|
|
}, [loadRoles, loadPermissions]);
|
|
|
|
const openCreate = () => {
|
|
setEditingRole(null);
|
|
setForm({ ...emptyForm });
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const openEdit = (role: any) => {
|
|
setEditingRole(role);
|
|
setForm({
|
|
name: role.name ?? '',
|
|
code: role.code ?? '',
|
|
description: role.description ?? '',
|
|
permission_ids: Array.isArray(role._permissionIds) ? [...role._permissionIds] : [],
|
|
});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.name.trim()) {
|
|
toast.error('请输入角色名称');
|
|
return;
|
|
}
|
|
if (!form.code.trim()) {
|
|
toast.error('请输入角色编码');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
code: form.code.trim(),
|
|
description: form.description.trim(),
|
|
permission_ids: form.permission_ids,
|
|
};
|
|
if (editingRole) {
|
|
await userApi.updateRole(editingRole.id, payload);
|
|
toast.success('角色更新成功');
|
|
} else {
|
|
await userApi.createRole(payload);
|
|
toast.success('角色创建成功');
|
|
}
|
|
setDialogOpen(false);
|
|
await loadRoles();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || '保存失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (role: any) => {
|
|
const ok = await confirm({
|
|
title: '删除角色',
|
|
description: `确认删除角色「${role.name}」吗?该操作不可恢复。`,
|
|
confirmText: '删除',
|
|
variant: 'destructive',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await userApi.deleteRole(role.id);
|
|
toast.success('角色已删除');
|
|
await loadRoles();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || '删除失败');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-end">
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
新建角色
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : roles.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
|
<Shield className="h-10 w-10 mb-3 opacity-40" />
|
|
<p className="text-sm">暂无角色数据</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{roles.map((role) => (
|
|
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
|
<Shield className="h-5 w-5 text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
|
|
<CardDescription className="text-slate-500">{role.code}</CardDescription>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
|
onClick={() => setSelectedRole(role)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
|
onClick={() => openEdit(role)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
|
onClick={() => handleDelete(role)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{role.permissions.slice(0, 5).map((permission) => (
|
|
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
|
{permission}
|
|
</Badge>
|
|
))}
|
|
{role.permissions.length > 5 && (
|
|
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
|
+{role.permissions.length - 5}
|
|
</Badge>
|
|
)}
|
|
{role.permissions.length === 0 && (
|
|
<span className="text-xs text-slate-600">未分配权限</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 角色详情 Dialog */}
|
|
<RoleDetailDialog role={selectedRole} onClose={() => setSelectedRole(null)} onEdit={(r) => { setSelectedRole(null); openEdit(r); }} />
|
|
|
|
{/* 新建 / 编辑角色 Dialog */}
|
|
<RoleFormDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
editingRole={editingRole}
|
|
form={form}
|
|
setForm={setForm}
|
|
saving={saving}
|
|
onSubmit={handleSubmit}
|
|
permissions={permissions}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 角色详情查看 Dialog
|
|
function RoleDetailDialog({
|
|
role,
|
|
onClose,
|
|
onEdit,
|
|
}: {
|
|
role: Role | null;
|
|
onClose: () => void;
|
|
onEdit: (role: Role) => void;
|
|
}) {
|
|
return (
|
|
<Dialog open={!!role} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent className="bg-slate-900 border-slate-800">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
|
<Shield className="h-5 w-5 text-purple-400" />
|
|
{role?.name}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-slate-500">编码:{role?.code}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg bg-slate-800/50 p-4">
|
|
<p className="text-xs text-slate-500 mb-1">描述</p>
|
|
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表</h4>
|
|
{role && role.permissions.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{role.permissions.map((p) => (
|
|
<Badge key={p} variant="outline" className="border-slate-700 text-slate-400">
|
|
{p}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-slate-500">未分配权限</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}>
|
|
<Edit className="h-4 w-4 mr-2" />
|
|
编辑
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|