203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
import {
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Col,
|
|
Empty,
|
|
Input,
|
|
Row,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
message
|
|
} from "antd";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
|
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
|
import type { SysRole, SysUser } from "@/types";
|
|
import PageHeader from "@/components/shared/PageHeader";
|
|
|
|
const { Text } = Typography;
|
|
|
|
export default function UserRoleBinding() {
|
|
const { t } = useTranslation();
|
|
const [users, setUsers] = useState<SysUser[]>([]);
|
|
const [roles, setRoles] = useState<SysRole[]>([]);
|
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
|
const [loadingRoles, setLoadingRoles] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
|
const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]);
|
|
const [searchText, setSearchText] = useState("");
|
|
|
|
const selectedUser = useMemo(() => users.find((user) => user.userId === selectedUserId) || null, [users, selectedUserId]);
|
|
|
|
const loadUsers = async () => {
|
|
setLoadingUsers(true);
|
|
try {
|
|
const list = await listUsers();
|
|
setUsers(list || []);
|
|
} finally {
|
|
setLoadingUsers(false);
|
|
}
|
|
};
|
|
|
|
const loadRoles = async () => {
|
|
setLoadingRoles(true);
|
|
try {
|
|
const list = await listRoles();
|
|
setRoles(list || []);
|
|
} finally {
|
|
setLoadingRoles(false);
|
|
}
|
|
};
|
|
|
|
const loadUserRoles = async (userId: number) => {
|
|
try {
|
|
const list = await listUserRoles(userId);
|
|
setCheckedRoleIds(list || []);
|
|
} catch {
|
|
setCheckedRoleIds([]);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadUsers();
|
|
loadRoles();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedUserId) {
|
|
loadUserRoles(selectedUserId);
|
|
} else {
|
|
setCheckedRoleIds([]);
|
|
}
|
|
}, [selectedUserId]);
|
|
|
|
const filteredUsers = useMemo(() => {
|
|
if (!searchText) return users;
|
|
const lower = searchText.toLowerCase();
|
|
return users.filter(
|
|
(user) => user.username.toLowerCase().includes(lower) || user.displayName.toLowerCase().includes(lower)
|
|
);
|
|
}, [users, searchText]);
|
|
|
|
const handleSave = async () => {
|
|
if (!selectedUserId) {
|
|
message.warning(t("userRole.selectUser"));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await saveUserRoles(selectedUserId, checkedRoleIds);
|
|
message.success(t("common.success"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="app-page">
|
|
<PageHeader
|
|
title={t("userRole.title")}
|
|
subtitle={t("userRole.subtitle")}
|
|
/>
|
|
|
|
<div className="app-page__page-actions">
|
|
<Button type="primary" icon={<SaveOutlined aria-hidden="true" />} onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
|
{saving ? t("common.loading") : t("common.save")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
|
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
|
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
|
|
<div className="mb-4">
|
|
<Input
|
|
placeholder={t("userRole.searchUser")}
|
|
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
|
value={searchText}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
allowClear
|
|
aria-label={t("userRole.searchUser")}
|
|
/>
|
|
</div>
|
|
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
|
|
<Table
|
|
rowKey="userId"
|
|
size="middle"
|
|
loading={loadingUsers}
|
|
dataSource={filteredUsers}
|
|
rowSelection={{
|
|
type: "radio",
|
|
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
|
onChange: (keys) => setSelectedUserId(keys[0] as number)
|
|
}}
|
|
onRow={(record) => ({
|
|
onClick: () => setSelectedUserId(record.userId),
|
|
className: "cursor-pointer"
|
|
})}
|
|
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
|
|
columns={[
|
|
{
|
|
title: t("users.userInfo"),
|
|
key: "user",
|
|
render: (_: unknown, record: SysUser) => (
|
|
<div className="min-w-0">
|
|
<div style={{ fontWeight: 500 }} className="truncate">{record.displayName}</div>
|
|
<div style={{ fontSize: 12, color: "#8c8c8c" }} className="truncate">@{record.username}</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
title: t("common.status"),
|
|
dataIndex: "status",
|
|
width: 80,
|
|
render: (value: number) => (value === 1 ? <Tag color="green" className="m-0">Enabled</Tag> : <Tag className="m-0">Disabled</Tag>)
|
|
}
|
|
]}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
|
<Card
|
|
title={<Space><TeamOutlined aria-hidden="true" /><span>{t("userRole.grantRoles")}</span></Space>}
|
|
className="app-page__panel-card full-height-card"
|
|
extra={selectedUser ? <Tag color="blue">{t("userRole.editing")}: {selectedUser.displayName}</Tag> : null}
|
|
>
|
|
{selectedUserId ? (
|
|
<div style={{ padding: "8px 0", height: "100%", overflowY: "auto" }}>
|
|
<Checkbox.Group style={{ width: "100%" }} value={checkedRoleIds} onChange={(values) => setCheckedRoleIds(values as number[])} disabled={loadingRoles}>
|
|
<Row gutter={[16, 16]}>
|
|
{roles.map((role) => (
|
|
<Col key={role.roleId} span={12}>
|
|
<Checkbox value={role.roleId} className="w-full">
|
|
<Space direction="vertical" size={0}>
|
|
<span style={{ fontWeight: 500 }}>{role.roleName}</span>
|
|
<Text type="secondary" style={{ fontSize: 12 }} className="tabular-nums">
|
|
{role.roleCode}
|
|
</Text>
|
|
</Space>
|
|
</Checkbox>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
</Checkbox.Group>
|
|
{!roles.length && !loadingRoles && <Empty description="No roles available" />}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
|
<UserOutlined style={{ fontSize: 40, color: "#bfbfbf", marginBottom: 16 }} aria-hidden="true" />
|
|
<Text type="secondary">{t("userRole.selectUser")}</Text>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
);
|
|
} |