461 lines
20 KiB
TypeScript
461 lines
20 KiB
TypeScript
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message,App } from "antd";
|
|
import type { DefaultOptionType } from "antd/es/select";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
|
|
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser, uploadPlatformAsset } from "@/api";
|
|
import AppPagination from "@/components/shared/AppPagination";
|
|
import { useDict } from "@/hooks/useDict";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import PageHeader from "@/components/shared/PageHeader";
|
|
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
|
import "./index.less";
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
type OrgTreeNode = { value: number; title: string; children: OrgTreeNode[] };
|
|
|
|
type Membership = { tenantId?: number; orgId?: number; orgName?: string };
|
|
|
|
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
|
|
const map = new Map<number, OrgTreeNode>();
|
|
const roots: OrgTreeNode[] = [];
|
|
|
|
list.forEach((item) => {
|
|
map.set(item.id, { value: item.id, title: item.orgName, children: [] });
|
|
});
|
|
|
|
map.forEach((node, id) => {
|
|
const item = list.find((org) => org.id === id);
|
|
if (item?.parentId && map.has(item.parentId)) {
|
|
map.get(item.parentId)!.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
return roots;
|
|
}
|
|
|
|
function MembershipOrgSelect({ fieldProps, name, tenantId }: { fieldProps: any; name: number; tenantId?: number }) {
|
|
const { t } = useTranslation();
|
|
const [orgs, setOrgs] = useState<OrgTreeNode[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!tenantId) {
|
|
setOrgs([]);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
listOrgs(tenantId)
|
|
.then((data) => setOrgs(buildOrgTree(data || [])))
|
|
.finally(() => setLoading(false));
|
|
}, [tenantId]);
|
|
|
|
return (
|
|
<Form.Item {...fieldProps} label={t("users.orgNode")} name={[name, "orgId"]}>
|
|
<TreeSelect placeholder={t("usersExt.selectDepartment")} allowClear treeData={orgs} loading={loading} disabled={!tenantId} />
|
|
</Form.Item>
|
|
);
|
|
}
|
|
|
|
export default function Users() {
|
|
const { message } = App.useApp();
|
|
const { t } = useTranslation();
|
|
const { can } = usePermission();
|
|
const { items: statusDict } = useDict("sys_common_status");
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
const [data, setData] = useState<SysUser[]>([]);
|
|
const [roles, setRoles] = useState<SysRole[]>([]);
|
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
|
const [orgs, setOrgs] = useState<SysOrg[]>([]);
|
|
const [current, setCurrent] = useState(1);
|
|
const [pageSize, setPageSize] = useState(10);
|
|
const [searchText, setSearchText] = useState("");
|
|
|
|
const handleSearch = () => {
|
|
setCurrent(1);
|
|
};
|
|
|
|
const handleResetSearch = () => {
|
|
setSearchText("");
|
|
setFilterTenantId(undefined);
|
|
setCurrent(1);
|
|
};
|
|
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [editing, setEditing] = useState<SysUser | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
const isPlatformMode = useMemo(() => {
|
|
const profileStr = sessionStorage.getItem("userProfile");
|
|
if (!profileStr) return false;
|
|
const profile = JSON.parse(profileStr);
|
|
return !!profile.isPlatformAdmin;
|
|
}, []);
|
|
|
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
|
const selectedTenantId = Form.useWatch("tenantId", form);
|
|
const memberships = (Form.useWatch("memberships", form) || []) as Membership[];
|
|
|
|
const tenantMap = useMemo(() => {
|
|
const map: Record<number, string> = {};
|
|
tenants.forEach((tenant) => {
|
|
map[tenant.id] = tenant.tenantName;
|
|
});
|
|
return map;
|
|
}, [tenants]);
|
|
|
|
const roleOptions = useMemo<DefaultOptionType[]>(() => {
|
|
if (!isPlatformMode) {
|
|
return roles.map((role) => ({ label: role.roleName, value: role.roleId }));
|
|
}
|
|
|
|
const selectedTenantIds = new Set(memberships.map((membership) => membership?.tenantId).filter(Boolean));
|
|
return roles
|
|
.filter((role: any) => role.tenantId != null && selectedTenantIds.has(role.tenantId))
|
|
.map((role: any) => {
|
|
const tenantId = role.tenantId ?? 0;
|
|
const tenantName = tenantMap[tenantId] || `Tenant:${tenantId}`;
|
|
return {
|
|
label: (
|
|
<div style={{ display: "flex", justifyContent: "space-between", width: "100%" }}>
|
|
<span>{role.roleName}</span>
|
|
<span style={{ color: "#bfbfbf", fontSize: "11px", marginLeft: 8 }}>[{tenantName}]</span>
|
|
</div>
|
|
),
|
|
value: role.roleId,
|
|
searchText: `${role.roleName} ${tenantName}`
|
|
};
|
|
});
|
|
}, [isPlatformMode, roles, memberships, tenantMap]);
|
|
|
|
const loadBaseData = async () => {
|
|
try {
|
|
const promises: Promise<any>[] = [listRoles()];
|
|
if (isPlatformMode) {
|
|
promises.push(listTenants({ current: 1, size: 1000 }));
|
|
}
|
|
const [rolesList, tenantsResponse] = await Promise.all(promises);
|
|
setRoles(rolesList || []);
|
|
if (isPlatformMode && tenantsResponse) {
|
|
setTenants(tenantsResponse.records || []);
|
|
}
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
const loadUsersData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const usersList = await listUsers({ tenantId: filterTenantId });
|
|
setData(usersList || []);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadBaseData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadUsersData();
|
|
}, [filterTenantId]);
|
|
|
|
useEffect(() => {
|
|
const fetchOrgs = async () => {
|
|
const targetId = isPlatformMode ? selectedTenantId : activeTenantId;
|
|
if (targetId) {
|
|
const list = await listOrgs(targetId);
|
|
setOrgs(list || []);
|
|
} else {
|
|
setOrgs([]);
|
|
}
|
|
};
|
|
fetchOrgs();
|
|
}, [selectedTenantId, isPlatformMode, activeTenantId]);
|
|
|
|
const orgTreeData = useMemo(() => buildOrgTree(orgs), [orgs]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!searchText) return data;
|
|
const lower = searchText.toLowerCase();
|
|
return data.filter(
|
|
(user) =>
|
|
user.username.toLowerCase().includes(lower) ||
|
|
user.displayName.toLowerCase().includes(lower) ||
|
|
(user.email && user.email.toLowerCase().includes(lower))
|
|
);
|
|
}, [data, searchText]);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
status: 1,
|
|
roleIds: [],
|
|
isPlatformAdmin: false,
|
|
tenantId: isPlatformMode ? undefined : activeTenantId,
|
|
memberships: isPlatformMode ? [] : [{ tenantId: activeTenantId }]
|
|
});
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const openEdit = async (record: SysUser) => {
|
|
setEditing(record);
|
|
try {
|
|
const detail = await getUserDetail(record.userId);
|
|
const roleIds = await listUserRoles(record.userId);
|
|
form.setFieldsValue({
|
|
...detail,
|
|
roleIds: roleIds || [],
|
|
password: "",
|
|
tenantId: (detail as any).tenantId || detail.memberships?.[0]?.tenantId,
|
|
orgId: (detail as any).orgId || detail.memberships?.[0]?.orgId,
|
|
memberships: detail.memberships || []
|
|
});
|
|
setDrawerOpen(true);
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
await deleteUser(id);
|
|
message.success(t("common.success"));
|
|
loadUsersData();
|
|
};
|
|
|
|
const handleAvatarUpload = async (file: File) => {
|
|
try {
|
|
setAvatarUploading(true);
|
|
const url = await uploadPlatformAsset(file);
|
|
form.setFieldValue("avatarUrl", url);
|
|
message.success(t("common.success"));
|
|
} finally {
|
|
setAvatarUploading(false);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const submit = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
const userPayload: Partial<SysUser> = {
|
|
username: values.username,
|
|
displayName: values.displayName,
|
|
email: values.email,
|
|
phone: values.phone,
|
|
avatarUrl: values.avatarUrl,
|
|
status: values.status,
|
|
isPlatformAdmin: values.isPlatformAdmin
|
|
};
|
|
|
|
if (!isPlatformMode) {
|
|
userPayload.memberships = [{ tenantId: activeTenantId, orgId: values.orgId } as any];
|
|
} else {
|
|
userPayload.memberships = values.memberships || [];
|
|
}
|
|
|
|
if (values.password) {
|
|
userPayload.password = values.password;
|
|
}
|
|
|
|
let userId = editing?.userId;
|
|
if (editing) {
|
|
await updateUser(editing.userId, userPayload);
|
|
} else {
|
|
await createUser(userPayload);
|
|
const updatedList = await listUsers();
|
|
const newUser = updatedList.find((user) => user.username === userPayload.username);
|
|
userId = newUser?.userId;
|
|
}
|
|
|
|
if (userId) {
|
|
await saveUserRoles(userId, values.roleIds || []);
|
|
}
|
|
|
|
message.success(t("common.success"));
|
|
setDrawerOpen(false);
|
|
loadUsersData();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const columns: any[] = [
|
|
{
|
|
title: t("users.userInfo"),
|
|
key: "user",
|
|
render: (_: any, record: SysUser) => (
|
|
<Space>
|
|
<Avatar className="user-avatar-placeholder" size={40} src={record.avatarUrl || undefined} icon={record.avatarUrl ? undefined : <UserOutlined />} />
|
|
<div>
|
|
<Space size={4}>
|
|
<div className="user-display-name">{record.displayName}</div>
|
|
{record.isPlatformAdmin && <Tag color="gold" style={{ fontSize: 10 }}>{t("users.platformAdmin")}</Tag>}
|
|
</Space>
|
|
<div className="user-username tabular-nums">@{record.username}</div>
|
|
</div>
|
|
</Space>
|
|
)
|
|
},
|
|
...(isPlatformMode
|
|
? [{
|
|
title: t("users.tenant"),
|
|
key: "tenant",
|
|
render: (_: any, record: SysUser) => {
|
|
if (record.memberships && record.memberships.length > 0) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{record.memberships.slice(0, 2).map((membership: any) => (
|
|
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
|
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
|
</Tag>
|
|
))}
|
|
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
|
</div>
|
|
);
|
|
}
|
|
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
|
}
|
|
}]
|
|
: []),
|
|
{
|
|
title: t("users.orgNode"),
|
|
key: "org",
|
|
render: (_: any, record: SysUser) => {
|
|
if (record.memberships && record.memberships.length > 0) {
|
|
const orgNames = record.memberships.map((membership: any) => membership.orgName).filter(Boolean);
|
|
if (orgNames.length > 0) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{orgNames.map((name: string, index: number) => (
|
|
<Space key={index} size={4} style={{ fontSize: 13, color: "#555" }}>
|
|
<ApartmentOutlined />
|
|
<span>{name}</span>
|
|
</Space>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
return <Text type="secondary">-</Text>;
|
|
}
|
|
},
|
|
{
|
|
title: t("users.roles"),
|
|
key: "roles",
|
|
render: (_: any, record: SysUser) => (
|
|
<Space wrap size={[0, 4]}>
|
|
{record.roles && record.roles.length > 0 ? record.roles.map((role) => <Tag key={role.roleId} color="cyan">{role.roleName}</Tag>) : <Text type="secondary">{t("usersExt.noRoles")}</Text>}
|
|
</Space>
|
|
)
|
|
},
|
|
{
|
|
title: t("common.status"),
|
|
dataIndex: "status",
|
|
width: 80,
|
|
render: (status: number) => {
|
|
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
|
|
return <Tag color={status === 1 ? "green" : "red"} className="m-0">{item ? item.itemLabel : status === 1 ? t("usersExt.enabled") : t("usersExt.disabled")}</Tag>;
|
|
}
|
|
},
|
|
{
|
|
title: t("common.action"),
|
|
key: "action",
|
|
width: 100,
|
|
fixed: "right" as const,
|
|
render: (_: any, record: SysUser) => (
|
|
<Space>
|
|
{can("sys:user:update") && <Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("common.edit")} />}
|
|
{can("sys:user:delete") && record.userId !== 1 && <Popconfirm title={t("usersExt.deleteConfirm")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => handleDelete(record.userId)}><Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} /></Popconfirm>}
|
|
</Space>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div className="app-page users-page">
|
|
<PageHeader title={t("users.title")} subtitle={t("users.subtitle")} />
|
|
|
|
<Card className="users-table-card app-page__filter-card" styles={{ body: { padding: "16px" } }}>
|
|
<div className="users-table-toolbar">
|
|
<Space size="middle" wrap className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
|
<Space size="middle" wrap className="app-page__toolbar">
|
|
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
|
|
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
|
|
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
|
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
|
</Space>
|
|
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
|
|
</Space>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
|
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
|
|
<Table rowKey="userId" columns={columns} dataSource={filteredData} loading={loading} size="middle" scroll={{ x: "max-content" }} pagination={false} />
|
|
</div>
|
|
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
|
|
</Card>
|
|
|
|
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
|
<Form form={form} layout="vertical" className="user-form">
|
|
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
|
<Row gutter={16}>
|
|
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }]}><Input placeholder={t("users.username")} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
|
|
<Col span={12}><Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}><Input placeholder={t("users.displayName")} /></Form.Item></Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
|
|
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
|
|
</Row>
|
|
<Form.Item label={t("profile.avatarUrl")} name="avatarUrl"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
|
|
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
|
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
|
|
</Upload>
|
|
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}><Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} /></Form.Item>
|
|
<Form.Item label={t("users.roles")} name="roleIds" rules={[{ required: true, message: t("users.roles") }]}><Select mode="multiple" placeholder={t("users.roles")} options={roleOptions} optionFilterProp={isPlatformMode ? "searchText" : "label"} /></Form.Item>
|
|
{!isPlatformMode && <Form.Item label={t("users.orgNode")} name="orgId"><TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData} /></Form.Item>}
|
|
<Row gutter={16}>
|
|
<Col span={12}><Form.Item label={t("common.status")} name="status" initialValue={1}><Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} /></Form.Item></Col>
|
|
{isPlatformMode && <Col span={12}><Form.Item label={t("users.platformAdmin")} name="isPlatformAdmin" valuePropName="checked"><Switch /></Form.Item></Col>}
|
|
</Row>
|
|
{isPlatformMode && (
|
|
<>
|
|
<Title level={5} style={{ marginTop: 24, marginBottom: 16 }}>{t("usersExt.membershipsTitle")}</Title>
|
|
<Form.List name="memberships">
|
|
{(fields, { add, remove }) => (
|
|
<>
|
|
{fields.map(({ key, name, ...restField }) => (
|
|
<Card key={key} size="small" className="mb-3" styles={{ body: { padding: "12px" } }} title={t("usersExt.membershipTitle", { index: name + 1 })} extra={fields.length > 1 && <Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />}>
|
|
<Row gutter={12}>
|
|
<Col span={12}>
|
|
<Form.Item {...restField} label={t("users.tenant")} name={[name, "tenantId"]} rules={[{ required: true, message: t("usersExt.membershipRequired") }]}>
|
|
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} placeholder={t("usersExt.selectTenant")} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<MembershipOrgSelect fieldProps={{ ...restField }} name={name} tenantId={form.getFieldValue(["memberships", name, "tenantId"])} />
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
))}
|
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>{t("usersExt.addMembership")}</Button>
|
|
</>
|
|
)}
|
|
</Form.List>
|
|
</>
|
|
)}
|
|
</Form>
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|