661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
import {
|
|
Button,
|
|
Card,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
message,
|
|
Popconfirm,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
Tree,
|
|
Row,
|
|
Col,
|
|
Tabs,
|
|
Empty,
|
|
Select,
|
|
Modal
|
|
} from "antd";
|
|
import type { DataNode } from "antd/es/tree";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
createRole,
|
|
listPermissions,
|
|
listRolePermissions,
|
|
listRoles,
|
|
saveRolePermissions,
|
|
updateRole,
|
|
deleteRole,
|
|
fetchUsersByRoleId,
|
|
bindUsersToRole,
|
|
unbindUserFromRole,
|
|
listUsers
|
|
} from "../api";
|
|
import type {SysPermission, SysRole, SysTenant, SysUser} from "../types";
|
|
import { usePermission } from "../hooks/usePermission";
|
|
import {
|
|
EditOutlined,
|
|
PlusOutlined,
|
|
SafetyCertificateOutlined,
|
|
SearchOutlined,
|
|
DeleteOutlined,
|
|
KeyOutlined,
|
|
UserOutlined,
|
|
SaveOutlined,
|
|
UserAddOutlined
|
|
} from "@ant-design/icons";
|
|
import "./Roles.css";
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
const DEFAULT_STATUS = 1;
|
|
|
|
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
|
|
|
|
const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
|
|
if (!list || list.length === 0) return [];
|
|
const active = list.filter((p) => p.status !== 0);
|
|
const map = new Map<number, PermissionNode>();
|
|
const roots: PermissionNode[] = [];
|
|
|
|
active.forEach((item) => {
|
|
map.set(item.permId, { ...item, key: item.permId, children: [] });
|
|
});
|
|
|
|
map.forEach((node) => {
|
|
if (node.parentId && node.parentId !== 0) {
|
|
const parent = map.get(node.parentId);
|
|
if (parent) {
|
|
parent.children!.push(node);
|
|
}
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
const sortNodes = (nodes: PermissionNode[]) => {
|
|
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
nodes.forEach((n) => n.children && sortNodes(n.children));
|
|
};
|
|
sortNodes(roots);
|
|
return roots;
|
|
};
|
|
|
|
const toTreeData = (nodes: PermissionNode[], t: any): DataNode[] =>
|
|
nodes.map((node) => ({
|
|
key: node.permId,
|
|
title: (
|
|
<span className="role-permission-node">
|
|
<span>{node.name}</span>
|
|
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}>{t('permissions.permType') === '按钮' ? '按钮' : 'Button'}</Tag>}
|
|
</span>
|
|
),
|
|
children: node.children && node.children.length > 0 ? toTreeData(node.children, t) : undefined
|
|
}));
|
|
|
|
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
|
|
|
|
export default function Roles() {
|
|
const { t } = useTranslation();
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [data, setData] = useState<SysRole[]>([]);
|
|
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
|
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
|
|
|
// Platform admin check
|
|
const isPlatformMode = useMemo(() => {
|
|
const profileStr = sessionStorage.getItem("userProfile");
|
|
if (profileStr) {
|
|
const profile = JSON.parse(profileStr);
|
|
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
|
}
|
|
return false;
|
|
}, []);
|
|
|
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
|
|
|
// Right side states
|
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
|
const [roleUsers, setRoleUsers] = useState<SysUser[]>([]);
|
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
|
|
|
// User selection states
|
|
const [allUsers, setAllUsers] = useState<SysUser[]>([]);
|
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
|
const [selectedUserKeys, setSelectedUserKeys] = useState<number[]>([]);
|
|
const [userSearchText, setUserSearchText] = useState("");
|
|
|
|
// Search
|
|
const [searchText, setSearchText] = useState("");
|
|
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
|
|
|
// Drawer (Only for Add/Edit basic info)
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [editing, setEditing] = useState<SysRole | null>(null);
|
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
|
const [form] = Form.useForm();
|
|
|
|
const { can } = usePermission();
|
|
|
|
const loadTenants = async () => {
|
|
if (!isPlatformMode) return;
|
|
try {
|
|
const resp = await (await import("../api")).listTenants({ current: 1, size: 100 });
|
|
setTenants(resp.records || []);
|
|
} catch (e) {}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadTenants();
|
|
}, [isPlatformMode]);
|
|
|
|
const permissionTreeData = useMemo(
|
|
() => toTreeData(buildPermissionTree(permissions), t),
|
|
[permissions, t]
|
|
);
|
|
|
|
const loadAllUsers = async () => {
|
|
try {
|
|
const list = await listUsers();
|
|
setAllUsers(list || []);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const openUserModal = () => {
|
|
loadAllUsers();
|
|
setSelectedUserKeys([]);
|
|
setUserModalOpen(true);
|
|
};
|
|
|
|
const handleAddUsers = async () => {
|
|
if (!selectedRole || selectedUserKeys.length === 0) return;
|
|
try {
|
|
await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
|
|
message.success(t('common.success'));
|
|
setUserModalOpen(false);
|
|
selectRole(selectedRole);
|
|
} catch (e) {
|
|
message.error(t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleUnbindUser = async (userId: number) => {
|
|
if (!selectedRole) return;
|
|
try {
|
|
await unbindUserFromRole(selectedRole.roleId, userId);
|
|
message.success(t('common.success'));
|
|
selectRole(selectedRole);
|
|
} catch (e) {
|
|
message.error(t('common.error'));
|
|
}
|
|
};
|
|
|
|
const filteredModalUsers = useMemo(() => {
|
|
const existingIds = new Set(roleUsers.map(u => u.userId));
|
|
return allUsers.filter(u =>
|
|
!existingIds.has(u.userId) &&
|
|
(u.username.toLowerCase().includes(userSearchText.toLowerCase()) ||
|
|
u.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
|
|
);
|
|
}, [allUsers, roleUsers, userSearchText]);
|
|
|
|
const loadPermissions = async () => {
|
|
try {
|
|
const list = await listPermissions();
|
|
setPermissions(list || []);
|
|
} catch (e) {
|
|
setPermissions([]);
|
|
}
|
|
};
|
|
|
|
const loadRoles = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const list = await listRoles();
|
|
let roles = list || [];
|
|
|
|
if (isPlatformMode && filterTenantId !== undefined) {
|
|
roles = roles.filter(r => r.tenantId === filterTenantId);
|
|
} else if (!isPlatformMode) {
|
|
roles = roles.filter(r => r.tenantId === activeTenantId);
|
|
}
|
|
|
|
setData(roles);
|
|
if (roles.length > 0 && !selectedRole) {
|
|
selectRole(roles[0]);
|
|
} else if (selectedRole) {
|
|
const updated = roles.find(r => r.roleId === selectedRole.roleId);
|
|
if (updated) setSelectedRole(updated);
|
|
}
|
|
await loadPermissions();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const selectRole = async (role: SysRole) => {
|
|
setSelectedRole(role);
|
|
try {
|
|
// Load permissions for this role
|
|
const ids = await listRolePermissions(role.roleId);
|
|
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
|
|
|
|
// Filter out parents for Tree回显
|
|
const leafIds = normalized.filter(id => {
|
|
return !permissions.some(p => p.parentId === id);
|
|
});
|
|
setSelectedPermIds(leafIds);
|
|
setHalfCheckedIds([]);
|
|
|
|
// Load users for this role
|
|
setLoadingUsers(true);
|
|
const users = await fetchUsersByRoleId(role.roleId);
|
|
setRoleUsers(users || []);
|
|
} catch (e) {
|
|
message.error(t('common.error'));
|
|
} finally {
|
|
setLoadingUsers(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadRoles();
|
|
}, []);
|
|
|
|
// Reload role detail if permissions list loaded later
|
|
useEffect(() => {
|
|
if (selectedRole && permissions.length > 0) {
|
|
const leafIds = selectedPermIds.filter(id => {
|
|
return !permissions.some(p => p.parentId === id);
|
|
});
|
|
if (leafIds.length !== selectedPermIds.length) {
|
|
setSelectedPermIds(leafIds);
|
|
}
|
|
}
|
|
}, [permissions]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!searchText) return data;
|
|
const lower = searchText.toLowerCase();
|
|
return data.filter(r =>
|
|
r.roleName.toLowerCase().includes(lower) ||
|
|
r.roleCode.toLowerCase().includes(lower)
|
|
);
|
|
}, [data, searchText]);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
status: 1,
|
|
tenantId: isPlatformMode ? undefined : activeTenantId
|
|
});
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const openEditBasic = (e: React.MouseEvent, record: SysRole) => {
|
|
e.stopPropagation();
|
|
setEditing(record);
|
|
form.setFieldsValue(record);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const handleRemove = async (e: React.MouseEvent, id: number) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await deleteRole(id);
|
|
message.success(t('common.success'));
|
|
if (selectedRole?.roleId === id) setSelectedRole(null);
|
|
loadRoles();
|
|
} catch (e) {
|
|
message.error(t('common.error'));
|
|
}
|
|
};
|
|
|
|
const submitBasic = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
const payload: Partial<SysRole> = {
|
|
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
|
roleName: values.roleName,
|
|
remark: values.remark,
|
|
status: values.status ?? DEFAULT_STATUS,
|
|
tenantId: values.tenantId
|
|
};
|
|
|
|
if (editing) {
|
|
await updateRole(editing.roleId, payload);
|
|
message.success(t('common.success'));
|
|
} else {
|
|
await createRole(payload);
|
|
message.success(t('common.success'));
|
|
}
|
|
|
|
setDrawerOpen(false);
|
|
loadRoles();
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message) message.error(e.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const savePermissions = async () => {
|
|
if (!selectedRole) return;
|
|
setSaving(true);
|
|
try {
|
|
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
|
|
await saveRolePermissions(selectedRole.roleId, allPermIds);
|
|
message.success(t('common.success'));
|
|
} catch (e) {
|
|
message.error(t('common.error'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="roles-page-v2 p-6">
|
|
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
|
|
{/* Left: Role List */}
|
|
<Col span={8} style={{ height: '100%' }}>
|
|
<Card
|
|
title={t('roles.title')}
|
|
className="full-height-card shadow-sm"
|
|
extra={can("sys:role:create") && (
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
icon={<PlusOutlined aria-hidden="true" />}
|
|
onClick={openCreate}
|
|
>
|
|
{t('common.create')}
|
|
</Button>
|
|
)}
|
|
>
|
|
<div className="mb-4 flex gap-2">
|
|
{isPlatformMode && (
|
|
<Select
|
|
placeholder={t('users.tenantFilter')}
|
|
style={{ width: 150 }}
|
|
allowClear
|
|
value={filterTenantId}
|
|
onChange={setFilterTenantId}
|
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
|
/>
|
|
)}
|
|
<Input
|
|
placeholder={t('roles.searchPlaceholder')}
|
|
prefix={<SearchOutlined aria-hidden="true" />}
|
|
value={searchText}
|
|
onChange={e => setSearchText(e.target.value)}
|
|
allowClear
|
|
aria-label={t('roles.searchPlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className="role-list-container" style={{ height: 'calc(100% - 60px)', overflowY: 'auto' }}>
|
|
<Table
|
|
rowKey="roleId"
|
|
showHeader={false}
|
|
dataSource={filteredData}
|
|
loading={loading}
|
|
pagination={false}
|
|
locale={{ emptyText: <Empty description={t('roles.selectRole')} /> }}
|
|
onRow={(record) => ({
|
|
onClick: () => selectRole(record),
|
|
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
|
|
})}
|
|
columns={[
|
|
{
|
|
title: '角色',
|
|
render: (_, record) => (
|
|
<div className="role-item-content flex justify-between items-center p-2">
|
|
<div className="role-item-main min-w-0">
|
|
<div className="role-item-name font-medium truncate">{record.roleName}</div>
|
|
<div className="role-item-code text-xs text-gray-400 truncate">{record.roleCode}</div>
|
|
</div>
|
|
<div className="role-item-actions flex gap-1">
|
|
{can("sys:role:update") && (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<EditOutlined aria-hidden="true" />}
|
|
onClick={e => openEditBasic(e, record)}
|
|
/>
|
|
)}
|
|
{can("sys:role:delete") && record.roleCode !== 'ADMIN' && (
|
|
<Popconfirm
|
|
title={`确定删除角色 "${record.roleName}" 吗?`}
|
|
onConfirm={e => handleRemove(e!, record.roleId)}
|
|
>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined aria-hidden="true" />}
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
</Popconfirm>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Right: Detail Tabs */}
|
|
<Col span={16} style={{ height: '100%' }}>
|
|
{selectedRole ? (
|
|
<Card
|
|
className="full-height-card shadow-sm"
|
|
title={
|
|
<Space>
|
|
<SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" />
|
|
<span className="truncate max-w-[200px] inline-block align-bottom">{selectedRole.roleName}</span>
|
|
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined aria-hidden="true" />}
|
|
loading={saving}
|
|
onClick={savePermissions}
|
|
disabled={!can("sys:role:permission:save")}
|
|
>
|
|
{t('roles.savePerms')}
|
|
</Button>
|
|
}
|
|
>
|
|
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
|
<Tabs.TabPane
|
|
tab={<Space><KeyOutlined aria-hidden="true" />{t('roles.funcPerms')}</Space>}
|
|
key="permissions"
|
|
>
|
|
<div className="role-permission-tree-v2" style={{ maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' }}>
|
|
<Tree
|
|
checkable
|
|
selectable={false}
|
|
checkStrictly={false}
|
|
treeData={permissionTreeData}
|
|
checkedKeys={selectedPermIds}
|
|
onCheck={(keys, info) => {
|
|
const checked = Array.isArray(keys) ? keys : keys.checked;
|
|
const halfChecked = info.halfCheckedKeys || [];
|
|
setSelectedPermIds(checked.map(k => Number(k)));
|
|
setHalfCheckedIds(halfChecked.map(k => Number(k)));
|
|
}}
|
|
defaultExpandAll
|
|
/>
|
|
</div>
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane
|
|
tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
|
|
key="users"
|
|
>
|
|
<div className="mb-4 flex justify-end">
|
|
<Button
|
|
type="primary"
|
|
icon={<UserAddOutlined aria-hidden="true" />}
|
|
onClick={openUserModal}
|
|
disabled={!can("sys:role:update")}
|
|
>
|
|
{t('common.create')}
|
|
</Button>
|
|
</div>
|
|
<Table
|
|
rowKey="userId"
|
|
size="small"
|
|
loading={loadingUsers}
|
|
dataSource={roleUsers}
|
|
pagination={{ pageSize: 10, showTotal: (total) => t('common.total', { total }) }}
|
|
columns={[
|
|
{
|
|
title: t('users.userInfo'),
|
|
render: (_, r) => (
|
|
<Space>
|
|
<UserOutlined aria-hidden="true" />
|
|
<div className="min-w-0">
|
|
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
|
|
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
|
|
</div>
|
|
</Space>
|
|
)
|
|
},
|
|
{ title: t('users.phone'), dataIndex: 'phone', className: 'tabular-nums' },
|
|
{ title: t('users.email'), dataIndex: 'email' },
|
|
{
|
|
title: t('common.status'),
|
|
dataIndex: 'status',
|
|
width: 80,
|
|
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
|
},
|
|
{
|
|
title: t('common.action'),
|
|
key: 'action',
|
|
width: 100,
|
|
render: (_, record) => (
|
|
<Popconfirm
|
|
title={t('common.delete') + "?"}
|
|
onConfirm={() => handleUnbindUser(record.userId)}
|
|
disabled={!can("sys:role:update")}
|
|
>
|
|
<Button
|
|
type="text"
|
|
danger
|
|
size="small"
|
|
icon={<DeleteOutlined aria-hidden="true" />}
|
|
disabled={!can("sys:role:update")}
|
|
/>
|
|
</Popconfirm>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</Tabs.TabPane>
|
|
</Tabs>
|
|
</Card>
|
|
) : (
|
|
<Card className="full-height-card flex items-center justify-center shadow-sm">
|
|
<Empty description={t('roles.selectRole')} />
|
|
</Card>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* User Selection Modal */}
|
|
<Modal
|
|
title={t('roles.assignedUsers')}
|
|
open={userModalOpen}
|
|
onCancel={() => setUserModalOpen(false)}
|
|
onOk={handleAddUsers}
|
|
width={600}
|
|
destroyOnClose
|
|
>
|
|
<div className="mb-4">
|
|
<Input
|
|
placeholder={t('users.searchPlaceholder')}
|
|
prefix={<SearchOutlined aria-hidden="true" />}
|
|
value={userSearchText}
|
|
onChange={e => setUserSearchText(e.target.value)}
|
|
allowClear
|
|
/>
|
|
</div>
|
|
<Table
|
|
rowKey="userId"
|
|
size="small"
|
|
dataSource={filteredModalUsers}
|
|
pagination={{ pageSize: 5 }}
|
|
rowSelection={{
|
|
selectedRowKeys: selectedUserKeys,
|
|
onChange: (keys) => setSelectedUserKeys(keys as number[])
|
|
}}
|
|
columns={[
|
|
{ title: t('users.displayName'), dataIndex: 'displayName' },
|
|
{ title: t('users.username'), dataIndex: 'username' },
|
|
{ title: t('users.org'), dataIndex: 'orgId', render: () => '-' } // Simplification
|
|
]}
|
|
/>
|
|
</Modal>
|
|
|
|
{/* Basic Info Drawer */}
|
|
<Drawer
|
|
title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
width={400}
|
|
destroyOnClose
|
|
footer={
|
|
<div className="flex justify-end gap-2 p-2">
|
|
<Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
|
|
<Button type="primary" loading={saving} onClick={submitBasic}>{t('common.confirm')}</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item
|
|
label={t('users.tenant')}
|
|
name="tenantId"
|
|
rules={[{ required: true }]}
|
|
hidden={!isPlatformMode}
|
|
>
|
|
<Select
|
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
|
disabled={!!editing}
|
|
/>
|
|
</Form.Item>
|
|
{!isPlatformMode && (
|
|
<Form.Item label={t('users.tenant')}>
|
|
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
|
|
<Input placeholder={t('roles.roleName')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('roles.roleCode')} name="roleCode" rules={[{ required: true }]}>
|
|
<Input placeholder={t('roles.roleCode')} disabled={!!editing} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.status')} name="status" initialValue={1}>
|
|
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.remark')} name="remark">
|
|
<Input.TextArea rows={3} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|