758 lines
29 KiB
TypeScript
758 lines
29 KiB
TypeScript
import {
|
|
Avatar,
|
|
Button,
|
|
Card,
|
|
Col,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Popconfirm,
|
|
Row,
|
|
Select,
|
|
Space,
|
|
Switch,
|
|
Tag,
|
|
Tooltip,
|
|
TreeSelect,
|
|
Typography,
|
|
Upload,
|
|
message
|
|
} from "antd";
|
|
import type { DefaultOptionType } from "antd/es/select";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ApartmentOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
KeyOutlined,
|
|
MinusCircleOutlined,
|
|
PlusOutlined,
|
|
QuestionCircleOutlined,
|
|
SearchOutlined,
|
|
ShopOutlined,
|
|
UploadOutlined,
|
|
UserOutlined
|
|
} from "@ant-design/icons";
|
|
import {
|
|
createUser,
|
|
deleteUser,
|
|
getUserDetail,
|
|
listOrgs,
|
|
listRoles,
|
|
listTenants,
|
|
listUserRoles,
|
|
listUsers,
|
|
resetUserPassword,
|
|
updateUser,
|
|
uploadPlatformAsset
|
|
} from "@/api";
|
|
import {fetchPublicPasswordPolicy, type PasswordPolicyPublic} from "@/api/auth";
|
|
import { useDict } from "@/hooks/useDict";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import PageContainer from "@/components/shared/PageContainer";
|
|
import DataListPanel from "@/components/shared/DataListPanel";
|
|
import AppPagination from "@/components/shared/AppPagination";
|
|
import ListTable from "@/components/shared/ListTable/ListTable";
|
|
import SectionCard from "@/components/shared/SectionCard";
|
|
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
|
import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password";
|
|
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; orgPath?: string; orgFullName?: string };
|
|
type ResetPasswordValues = { newPassword: string; confirmPassword: string };
|
|
|
|
const getMembershipOrgName = (membership: Membership) => {
|
|
return membership.orgFullName || membership.orgPath || membership.orgName || "";
|
|
};
|
|
|
|
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 { 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 [roleSelectOpen, setRoleSelectOpen] = useState(false);
|
|
const [form] = Form.useForm();
|
|
const [resetPasswordOpen, setResetPasswordOpen] = useState(false);
|
|
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
|
const [resetPasswordTarget, setResetPasswordTarget] = useState<SysUser | null>(null);
|
|
const [passwordPolicy, setPasswordPolicy] = useState<PasswordPolicyPublic | null>(null);
|
|
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
|
|
|
|
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 passwordValue = Form.useWatch("password", form);
|
|
|
|
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(() => {
|
|
void (async () => {
|
|
try {
|
|
setPasswordPolicy(await fetchPublicPasswordPolicy());
|
|
} catch {
|
|
setPasswordPolicy(null);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const policyHints = useMemo(() => buildPolicyHints(passwordPolicy), [passwordPolicy]);
|
|
|
|
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);
|
|
setRoleSelectOpen(false);
|
|
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);
|
|
setRoleSelectOpen(false);
|
|
try {
|
|
const detail = await getUserDetail(record.userId);
|
|
const roleIds = await listUserRoles(record.userId);
|
|
form.setFieldsValue({
|
|
...detail,
|
|
roleIds: roleIds || [],
|
|
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"));
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : t("common.error"));
|
|
return Upload.LIST_IGNORE;
|
|
} 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,
|
|
roleIds: values.roleIds || []
|
|
};
|
|
|
|
if (!isPlatformMode) {
|
|
userPayload.memberships = [{ tenantId: activeTenantId, orgId: values.orgId } as any];
|
|
} else {
|
|
userPayload.memberships = values.memberships || [];
|
|
}
|
|
|
|
if (editing) {
|
|
await updateUser(editing.userId, userPayload);
|
|
} else {
|
|
if (values.password) {
|
|
userPayload.password = values.password;
|
|
}
|
|
await createUser(userPayload);
|
|
}
|
|
|
|
message.success(t("common.success"));
|
|
setDrawerOpen(false);
|
|
loadUsersData();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const openResetPassword = (record: SysUser) => {
|
|
setResetPasswordTarget(record);
|
|
resetPasswordForm.resetFields();
|
|
setResetPasswordOpen(true);
|
|
};
|
|
|
|
const submitResetPassword = async () => {
|
|
if (!resetPasswordTarget) {
|
|
return;
|
|
}
|
|
const values = await resetPasswordForm.validateFields();
|
|
setResetPasswordLoading(true);
|
|
try {
|
|
await resetUserPassword(resetPasswordTarget.userId, {newPassword: values.newPassword});
|
|
message.success("密码已重置");
|
|
setResetPasswordOpen(false);
|
|
setResetPasswordTarget(null);
|
|
resetPasswordForm.resetFields();
|
|
} finally {
|
|
setResetPasswordLoading(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",
|
|
width: 260,
|
|
render: (_: any, record: SysUser) => {
|
|
if (record.memberships && record.memberships.length > 0) {
|
|
const tenantNames = Array.from(
|
|
new Set(
|
|
record.memberships
|
|
.map((membership: Membership) => tenantMap[membership.tenantId || 0] || (membership.tenantId ? `Tenant ${membership.tenantId}` : ""))
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
return (
|
|
<div className="user-affiliation-list">
|
|
{tenantNames.map((name, index) => (
|
|
<Tooltip key={`${name}-${index}`} title={name}>
|
|
<span className="user-affiliation-tag user-affiliation-tag--tenant">
|
|
<ShopOutlined aria-hidden="true" />
|
|
<span className="user-affiliation-tag__text">{name}</span>
|
|
</span>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
|
}
|
|
}]
|
|
: []),
|
|
{
|
|
title: t("users.orgNode"),
|
|
key: "org",
|
|
width: 340,
|
|
render: (_: any, record: SysUser) => {
|
|
if (record.memberships && record.memberships.length > 0) {
|
|
const orgNames = Array.from(
|
|
new Set(record.memberships.map((membership: Membership) => getMembershipOrgName(membership).trim()).filter(Boolean))
|
|
);
|
|
if (orgNames.length > 0) {
|
|
return (
|
|
<div className="user-affiliation-list">
|
|
{orgNames.map((name: string, index: number) => (
|
|
<Tooltip key={`${name}-${index}`} title={name}>
|
|
<span className="user-affiliation-tag user-affiliation-tag--org">
|
|
<ApartmentOutlined aria-hidden="true" />
|
|
<span className="user-affiliation-tag__text">{name}</span>
|
|
</span>
|
|
</Tooltip>
|
|
))}
|
|
</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: 200,
|
|
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:update") && (
|
|
<Tooltip title="重置密码">
|
|
<Button type="text" icon={<KeyOutlined aria-hidden="true"/>} onClick={() => openResetPassword(record)}
|
|
aria-label="重置密码"/>
|
|
</Tooltip>
|
|
)}
|
|
{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 (
|
|
<PageContainer title={null} className="users-page">
|
|
<SectionCard
|
|
title={t("users.title")}
|
|
description={t("users.subtitle")}
|
|
>
|
|
<DataListPanel
|
|
leftActions={
|
|
can("sys:user:create") ? (
|
|
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
|
{t("common.create")}
|
|
</Button>
|
|
) : null
|
|
}
|
|
rightActions={
|
|
<Space size={8} wrap>
|
|
{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>
|
|
}
|
|
footer={
|
|
<AppPagination
|
|
current={current}
|
|
pageSize={pageSize}
|
|
total={filteredData.length}
|
|
onChange={(page, size) => {
|
|
setCurrent(page);
|
|
setPageSize(size);
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<ListTable
|
|
rowKey="userId"
|
|
columns={columns}
|
|
dataSource={filteredData.slice((current - 1) * pageSize, current * pageSize)}
|
|
loading={loading}
|
|
scroll={{ y: "100%" }}
|
|
pagination={false}
|
|
/>
|
|
</DataListPanel>
|
|
</SectionCard>
|
|
|
|
<Drawer title={<div className="user-drawer-title"><UserOutlined aria-hidden="true"/><span>{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</span>
|
|
</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose
|
|
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")
|
|
}, {
|
|
pattern: LOGIN_NAME_PATTERN,
|
|
message: t("usersExt.usernameFormatTip", {defaultValue: "登录名只能输入数字、小写英文、@ 和 _"})
|
|
}]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)}
|
|
extra={t("usersExt.usernameFormatTip", {defaultValue: "登录名只能输入数字、小写英文、@ 和 _"})}><Input
|
|
placeholder={t("usersExt.usernamePlaceholder", {defaultValue: "仅支持 a-z、0-9、@、_"})} 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"
|
|
rules={[{required: true, message: "请输入邮箱地址"}, {
|
|
type: "email",
|
|
message: "请输入正确的邮箱格式"
|
|
}]}><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>
|
|
{isPlatformMode && (
|
|
<div className="user-membership-section">
|
|
<Title level={5} className="user-membership-title user-membership-title--required">{t("usersExt.membershipsTitle")}</Title>
|
|
<Form.List
|
|
name="memberships"
|
|
rules={[
|
|
{
|
|
validator: async (_, value?: Membership[]) => {
|
|
if (value?.some((membership) => membership?.tenantId)) {
|
|
return;
|
|
}
|
|
throw new Error(t("usersExt.membershipRequired"));
|
|
}
|
|
}
|
|
]}
|
|
>
|
|
{(fields, { add, remove }, { errors }) => (
|
|
<>
|
|
{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>
|
|
))}
|
|
<Form.ErrorList errors={errors} />
|
|
<Button type="dashed" onClick={() => add()} block
|
|
icon={<PlusOutlined/>}>{t("usersExt.addMembership")}</Button>
|
|
</>
|
|
)}
|
|
</Form.List>
|
|
</div>
|
|
)}
|
|
{!isPlatformMode && (
|
|
<Form.Item
|
|
label={t("users.orgNode")}
|
|
name="orgId"
|
|
rules={[{ required: true, message: t("usersExt.selectOrgPlaceholder") }]}
|
|
>
|
|
<TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData}/>
|
|
</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}
|
|
open={roleSelectOpen}
|
|
onDropdownVisibleChange={setRoleSelectOpen}
|
|
onChange={() => setRoleSelectOpen(false)}
|
|
optionFilterProp={isPlatformMode ? "searchText" : "label"}/></Form.Item>
|
|
{!editing ? (
|
|
<Form.Item
|
|
label={t("users.password")}
|
|
name="password"
|
|
validateFirst
|
|
rules={[
|
|
{required: true, message: t("users.password")},
|
|
{validator: buildPasswordPolicyValidator(passwordPolicy, () => form.getFieldValue("username"))}
|
|
]}
|
|
>
|
|
<Input.Password placeholder={t("usersExt.passwordInitPlaceholder")} autoComplete="new-password"/>
|
|
</Form.Item>
|
|
) : null}
|
|
{!editing && passwordValue ? (
|
|
<Form.Item
|
|
label={t("usersExt.confirmPassword")}
|
|
name="confirmPassword"
|
|
dependencies={["password"]}
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
required: !editing || !!getFieldValue("password"),
|
|
message: t("usersExt.confirmPassword"),
|
|
}),
|
|
({ getFieldValue }) => ({
|
|
validator(_, value) {
|
|
if (!value || getFieldValue("password") === value) {
|
|
return Promise.resolve();
|
|
}
|
|
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
|
|
},
|
|
}),
|
|
]}
|
|
>
|
|
<Input.Password placeholder={t("usersExt.confirmPasswordPlaceholder")} autoComplete="new-password" />
|
|
</Form.Item>
|
|
) : null}
|
|
<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>
|
|
</Form>
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title={resetPasswordTarget ? `重置密码: ${resetPasswordTarget.displayName}` : "重置密码"}
|
|
open={resetPasswordOpen}
|
|
onCancel={() => {
|
|
setResetPasswordOpen(false);
|
|
setResetPasswordTarget(null);
|
|
resetPasswordForm.resetFields();
|
|
}}
|
|
onOk={() => void submitResetPassword()}
|
|
confirmLoading={resetPasswordLoading}
|
|
destroyOnClose
|
|
>
|
|
<Form form={resetPasswordForm} layout="vertical">
|
|
<Form.Item
|
|
label={
|
|
<Space size={4}>
|
|
<span>新密码</span>
|
|
<Tooltip
|
|
title={
|
|
policyHints.length > 0 ? (
|
|
<ul style={{paddingLeft: 18, margin: 0, fontSize: 13, lineHeight: 1.6}}>
|
|
{policyHints.map((hint) => (
|
|
<li key={hint}>{hint}</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
t("profile.passwordRules")
|
|
)
|
|
}
|
|
overlayStyle={{maxWidth: 300}}
|
|
>
|
|
<QuestionCircleOutlined style={{color: "rgba(0,0,0,0.45)", cursor: "help"}}/>
|
|
</Tooltip>
|
|
</Space>
|
|
}
|
|
name="newPassword"
|
|
validateFirst
|
|
rules={[
|
|
{required: true, message: "请输入新密码"},
|
|
{validator: buildPasswordPolicyValidator(passwordPolicy, () => resetPasswordTarget?.username)}
|
|
]}
|
|
>
|
|
<Input.Password autoComplete="new-password"/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
label="确认新密码"
|
|
name="confirmPassword"
|
|
dependencies={["newPassword"]}
|
|
rules={[
|
|
{required: true, message: "请再次输入新密码"},
|
|
({getFieldValue}) => ({
|
|
validator(_, value) {
|
|
if (!value || getFieldValue("newPassword") === value) {
|
|
return Promise.resolve();
|
|
}
|
|
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
|
|
}
|
|
})
|
|
]}
|
|
>
|
|
<Input.Password autoComplete="new-password"/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|