imeeting/frontend/src/pages/bindings/user-role/index.tsx

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>
);
}