458 lines
22 KiB
TypeScript
458 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
|
import * as AntIcons from "@ant-design/icons";
|
|
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
|
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
|
|
import { useDict } from "@/hooks/useDict";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import PageHeader from "@/components/shared/PageHeader";
|
|
import type { SysPermission } from "@/types";
|
|
import "./index.less";
|
|
|
|
const { Text } = Typography;
|
|
type TreePermission = SysPermission & { key: number; children?: TreePermission[] };
|
|
|
|
const legacyIconAliases: Record<string, string> = {
|
|
dashboard: "DashboardOutlined",
|
|
meeting: "VideoCameraOutlined",
|
|
user: "UserOutlined",
|
|
role: "TeamOutlined",
|
|
permission: "SafetyCertificateOutlined",
|
|
device: "DesktopOutlined",
|
|
tenant: "ShopOutlined",
|
|
org: "ApartmentOutlined",
|
|
dict: "BookOutlined",
|
|
setting: "SettingOutlined"
|
|
};
|
|
|
|
const menuIconOptions = Object.keys(AntIcons)
|
|
.filter((key) => /(?:Outlined|Filled|TwoTone)$/.test(key))
|
|
.sort((left, right) => left.localeCompare(right));
|
|
|
|
function renderSelectableIcon(iconName?: string) {
|
|
const resolvedName = iconName ? (legacyIconAliases[iconName] || iconName) : undefined;
|
|
if (!resolvedName) return null;
|
|
const iconsMap = AntIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>;
|
|
const IconComponent = iconsMap[resolvedName];
|
|
return IconComponent ? <IconComponent aria-hidden="true" /> : null;
|
|
}
|
|
|
|
function buildTree(list: SysPermission[]): TreePermission[] {
|
|
const map = new Map<number, TreePermission>();
|
|
const roots: TreePermission[] = [];
|
|
|
|
(list || []).forEach((item) => {
|
|
map.set(item.permId, { ...item, key: item.permId, children: [] });
|
|
});
|
|
|
|
map.forEach((node) => {
|
|
if (node.parentId && map.has(node.parentId)) {
|
|
map.get(node.parentId)!.children!.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
const sortChildren = (nodes: TreePermission[]) => {
|
|
nodes.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
|
|
nodes.forEach((node) => node.children && sortChildren(node.children));
|
|
};
|
|
|
|
sortChildren(roots);
|
|
return roots;
|
|
}
|
|
|
|
export default function Permissions() {
|
|
const { message } = App.useApp();
|
|
const { t } = useTranslation();
|
|
const { can } = usePermission();
|
|
const { items: statusDict } = useDict("sys_common_status");
|
|
const { items: typeDict } = useDict("sys_permission_type");
|
|
const { items: visibleDict } = useDict("sys_common_visibility");
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [data, setData] = useState<SysPermission[]>([]);
|
|
const [query, setQuery] = useState({ name: "", code: "", permType: "" });
|
|
const [open, setOpen] = useState(false);
|
|
const [editing, setEditing] = useState<SysPermission | null>(null);
|
|
const [iconSearchKeyword, setIconSearchKeyword] = useState("");
|
|
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
|
const [visibleIconCount, setVisibleIconCount] = useState(120);
|
|
const iconGridRef = useRef<HTMLDivElement | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const list = await listMyPermissions();
|
|
setData(list || []);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
return data.filter((permission) => {
|
|
const hitName = query.name ? permission.name?.includes(query.name) : true;
|
|
const hitCode = query.code ? permission.code?.includes(query.code) : true;
|
|
const hitType = query.permType ? permission.permType === query.permType : true;
|
|
return hitName && hitCode && hitType;
|
|
});
|
|
}, [data, query]);
|
|
|
|
const treeData = useMemo(() => buildTree(filtered), [filtered]);
|
|
const currentPermType = Form.useWatch("permType", form);
|
|
const selectedIcon = Form.useWatch("icon", form);
|
|
|
|
const parentOptions = useMemo(() => {
|
|
return data
|
|
.filter((permission) => (currentPermType === "button" ? permission.permType === "menu" : permission.permType === "directory"))
|
|
.map((permission) => ({ value: permission.permId, label: permission.name }));
|
|
}, [data, currentPermType]);
|
|
|
|
const filteredIconOptions = useMemo(() => {
|
|
const keyword = iconSearchKeyword.trim().toLowerCase();
|
|
if (!keyword) return menuIconOptions;
|
|
return menuIconOptions.filter((iconName) => iconName.toLowerCase().includes(keyword));
|
|
}, [iconSearchKeyword]);
|
|
|
|
const visibleIconOptions = useMemo(() => filteredIconOptions.slice(0, visibleIconCount), [filteredIconOptions, visibleIconCount]);
|
|
|
|
useEffect(() => {
|
|
setVisibleIconCount(120);
|
|
}, [iconSearchKeyword, iconPickerOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!iconPickerOpen || !selectedIcon || iconSearchKeyword.trim()) return;
|
|
const selectedIndex = filteredIconOptions.findIndex((iconName) => iconName === selectedIcon);
|
|
if (selectedIndex >= 0 && selectedIndex + 24 > visibleIconCount) {
|
|
setVisibleIconCount(Math.min(selectedIndex + 60, filteredIconOptions.length));
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
const selectedNode = iconGridRef.current?.querySelector(`[data-icon-name="${selectedIcon}"]`) as HTMLButtonElement | null;
|
|
selectedNode?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}, 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [filteredIconOptions, iconPickerOpen, iconSearchKeyword, selectedIcon, visibleIconCount]);
|
|
|
|
const clearSelectedIcon = () => {
|
|
form.setFieldValue("icon", undefined);
|
|
setIconSearchKeyword("");
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setIconSearchKeyword("");
|
|
setIconPickerOpen(false);
|
|
form.setFieldsValue({ level: 1, permType: "directory", status: 1, isVisible: 1, sortOrder: 0 });
|
|
setOpen(true);
|
|
};
|
|
|
|
const openEdit = (record: SysPermission) => {
|
|
setEditing(record);
|
|
setIconSearchKeyword(record.icon || "");
|
|
setIconPickerOpen(false);
|
|
form.setFieldsValue(record);
|
|
setOpen(true);
|
|
};
|
|
|
|
const openAddChild = (record: SysPermission) => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setIconSearchKeyword("");
|
|
setIconPickerOpen(false);
|
|
const parentLevel = record.level || 1;
|
|
const newLevel = Math.min(parentLevel + 1, 3);
|
|
let defaultType = "menu";
|
|
if (newLevel === 3) defaultType = "button";
|
|
if (newLevel === 1) defaultType = "directory";
|
|
form.setFieldsValue({ parentId: record.permId, level: newLevel, permType: defaultType, status: 1, isVisible: 1, sortOrder: 0 });
|
|
setOpen(true);
|
|
};
|
|
|
|
const submit = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
let calculatedLevel = 1;
|
|
if (values.parentId) {
|
|
const parent = data.find((permission) => permission.permId === values.parentId);
|
|
if (parent) calculatedLevel = (parent.level || 1) + 1;
|
|
}
|
|
|
|
const payload: Partial<SysPermission> = {
|
|
parentId: values.parentId || 0,
|
|
name: values.name,
|
|
code: values.code,
|
|
permType: values.permType,
|
|
level: calculatedLevel,
|
|
path: values.path,
|
|
component: values.component,
|
|
icon: values.icon,
|
|
sortOrder: values.sortOrder,
|
|
isVisible: values.isVisible,
|
|
status: values.status,
|
|
description: values.description
|
|
};
|
|
|
|
if (editing) {
|
|
await updatePermission(editing.permId, payload);
|
|
} else {
|
|
await createPermission(payload);
|
|
}
|
|
|
|
message.success(t("common.success"));
|
|
setOpen(false);
|
|
load();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (id: number) => {
|
|
await deletePermission(id);
|
|
message.success(t("common.success"));
|
|
load();
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: t("permissions.permName"),
|
|
dataIndex: "name",
|
|
key: "name",
|
|
render: (text: string, record: TreePermission) => {
|
|
let icon = <CheckSquareOutlined style={{ color: "#52c41a" }} aria-hidden="true" />;
|
|
if (record.permType === "directory") icon = <FolderOutlined style={{ color: "#faad14" }} aria-hidden="true" />;
|
|
if (record.permType === "menu") icon = <MenuOutlined style={{ color: "#1890ff" }} aria-hidden="true" />;
|
|
return <Space>{icon}<Text strong={record.level === 1}>{text}</Text></Space>;
|
|
}
|
|
},
|
|
{
|
|
title: t("permissions.permCode"),
|
|
dataIndex: "code",
|
|
key: "code",
|
|
render: (text: string) => text ? <Tag color="blue" className="tabular-nums">{text}</Tag> : "-"
|
|
},
|
|
{
|
|
title: t("permissions.permType"),
|
|
dataIndex: "permType",
|
|
width: 90,
|
|
render: (type: string) => {
|
|
const item = typeDict.find((dictItem) => dictItem.itemValue === type);
|
|
let color: "warning" | "default" | "processing" = "warning";
|
|
if (type === "directory") color = "default";
|
|
if (type === "menu") color = "processing";
|
|
return <Tag color={color}>{item ? item.itemLabel : type}</Tag>;
|
|
}
|
|
},
|
|
{ title: t("permissions.sort"), dataIndex: "sortOrder", width: 80, className: "tabular-nums" },
|
|
{
|
|
title: t("permissions.route"),
|
|
key: "route",
|
|
ellipsis: true,
|
|
render: (_: unknown, record: TreePermission) => (
|
|
<div className="flex flex-col">
|
|
{record.path && <Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">{record.path}</Text>}
|
|
{record.component && <Text type="secondary" style={{ fontSize: "11px" }}>{record.component}</Text>}
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
title: t("permissions.visible"),
|
|
dataIndex: "isVisible",
|
|
width: 80,
|
|
render: (value: number) => {
|
|
const item = visibleDict.find((dictItem) => dictItem.itemValue === String(value));
|
|
return value === 1 ? <Tag color="blue">{item?.itemLabel || t("permissionsExt.visible")}</Tag> : <Tag>{item?.itemLabel || t("permissionsExt.hidden")}</Tag>;
|
|
}
|
|
},
|
|
{
|
|
title: t("common.status"),
|
|
dataIndex: "status",
|
|
width: 80,
|
|
render: (value: number) => {
|
|
const item = statusDict.find((dictItem) => dictItem.itemValue === String(value));
|
|
return value === 1 ? <Tag color="green">{item?.itemLabel || t("permissionsExt.enabled")}</Tag> : <Tag color="red">{item?.itemLabel || t("permissionsExt.disabled")}</Tag>;
|
|
}
|
|
},
|
|
{
|
|
title: t("common.action"),
|
|
width: 150,
|
|
fixed: "right" as const,
|
|
render: (_: unknown, record: TreePermission) => (
|
|
<Space>
|
|
{can("sys:permission:create") && record.permType !== "button" && <Tooltip title={t("permissionsExt.addChild")}><Button type="text" size="small" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openAddChild(record)} aria-label={t("permissionsExt.addChild")} /></Tooltip>}
|
|
{can("sys:permission:update") && <Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("common.edit")} />}
|
|
{can("sys:permission:delete") && <Popconfirm title={t("permissionsExt.deleteConfirm", { name: record.name })} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => remove(record.permId)}><Button type="text" size="small" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} /></Popconfirm>}
|
|
</Space>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div className="app-page permissions-page">
|
|
<PageHeader title={t("permissions.title")} subtitle={t("permissions.subtitle")} />
|
|
|
|
<Card className="app-page__filter-card" styles={{ body: { padding: "16px" } }}>
|
|
<Space wrap size="middle" className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
|
<Space wrap size="middle" className="app-page__toolbar">
|
|
<Input placeholder={t("permissions.permName")} value={query.name} onChange={(event) => setQuery({ ...query, name: event.target.value })} prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />} style={{ width: 180 }} allowClear aria-label={t("permissions.permName")} />
|
|
<Input placeholder={t("permissions.permCode")} value={query.code} onChange={(event) => setQuery({ ...query, code: event.target.value })} style={{ width: 180 }} allowClear aria-label={t("permissions.permCode")} />
|
|
<Select placeholder={t("permissions.permType")} allowClear value={query.permType || undefined} onChange={(value) => setQuery({ ...query, permType: value || "" })} options={typeDict.map((item) => ({ value: item.itemValue, label: item.itemLabel }))} style={{ width: 120 }} aria-label={t("permissions.permType")} />
|
|
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={load}>{t("common.search")}</Button>
|
|
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={() => setQuery({ name: "", code: "", permType: "" })}>{t("common.reset")}</Button>
|
|
</Space>
|
|
{can("sys:permission:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card className="app-page__content-card permissions-content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
|
<Table className="permissions-table-full" rowKey="permId" loading={loading} dataSource={treeData} columns={columns} pagination={false} size="middle" scroll={{ x: 'max-content', y: '100%' }} expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length }} />
|
|
</Card>
|
|
|
|
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
|
<Form form={form} layout="vertical" className="permission-form" onValuesChange={(changed) => changed.permType === "button" && form.setFieldsValue({ isVisible: 0 })}>
|
|
<Row gutter={16}>
|
|
<Col span={24}>
|
|
<Form.Item label={t("permissions.permType")} name="permType" rules={[{ required: true }]}>
|
|
<Select options={typeDict.map((item) => ({ value: item.itemValue, label: item.itemLabel }))} aria-label={t("permissions.permType")} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item label={t("permissions.parentId")} name="parentId">
|
|
<Select allowClear showSearch placeholder={t("permissionsExt.parentPlaceholder")} options={parentOptions} aria-label={t("permissions.parentId")} />
|
|
</Form.Item>
|
|
<Form.Item label={t("permissions.permName")} name="name" rules={[{ required: true, message: t("permissions.permName") }]}>
|
|
<Input placeholder={t("permissionsExt.namePlaceholder")} />
|
|
</Form.Item>
|
|
<Form.Item label={<Space><span>{t("permissions.permCode")}</span><Tooltip title={t("permissionsExt.codeHelp")}><InfoCircleOutlined className="text-gray-400" /></Tooltip></Space>} name="code" rules={[{ required: true, message: t("permissions.permCode") }]}>
|
|
<Input placeholder={t("permissionsExt.codePlaceholder")} className="tabular-nums" />
|
|
</Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item label={t("permissions.path")} name="path">
|
|
<Input placeholder={t("permissionsExt.pathPlaceholder")} className="tabular-nums" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item label={t("permissions.component")} name="component">
|
|
<Input placeholder={t("permissionsExt.componentPlaceholder")} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={24}>
|
|
<Form.Item label={t("permissions.icon")}>
|
|
<Form.Item name="icon" noStyle>
|
|
<Input type="hidden" />
|
|
</Form.Item>
|
|
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
|
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
|
<Input
|
|
readOnly
|
|
value={selectedIcon || ""}
|
|
onClick={() => {
|
|
setIconSearchKeyword("");
|
|
setIconPickerOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
setIconSearchKeyword("");
|
|
setIconPickerOpen(true);
|
|
}}
|
|
placeholder={t("permissionsExt.iconPlaceholder")}
|
|
prefix={renderSelectableIcon(selectedIcon) || <SearchOutlined aria-hidden="true" />}
|
|
style={{ width: 260 }}
|
|
/>
|
|
<Space>
|
|
<Button size="small" onClick={() => {
|
|
if (!iconPickerOpen) {
|
|
setIconSearchKeyword("");
|
|
}
|
|
setIconPickerOpen((value) => !value);
|
|
}}>{iconPickerOpen ? t("permissionsExt.hideIconLibrary") : t("permissionsExt.showIconLibrary")}</Button>
|
|
{selectedIcon ? <Button size="small" onClick={clearSelectedIcon}>{t("common.reset")}</Button> : null}
|
|
</Space>
|
|
</Space>
|
|
{iconPickerOpen ? <div><Input value={iconSearchKeyword} onChange={(event) => setIconSearchKeyword(event.target.value)} placeholder={t("permissionsExt.iconSearchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} allowClear style={{ marginBottom: 12 }} /><div ref={iconGridRef} onScroll={(event) => {
|
|
const target = event.currentTarget;
|
|
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 48) {
|
|
setVisibleIconCount((count) => Math.min(count + 120, filteredIconOptions.length));
|
|
}
|
|
}} style={{ maxHeight: 240, overflowY: "auto", border: "1px solid #f0f0f0", borderRadius: 8, padding: 12, background: "#fafafa" }}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(44px, 1fr))", gap: 10 }}>
|
|
{visibleIconOptions.map((iconName) => {
|
|
const active = selectedIcon === iconName;
|
|
return (
|
|
<Tooltip key={iconName} title={iconName}>
|
|
<Button
|
|
data-icon-name={iconName}
|
|
type="text"
|
|
onClick={() => {
|
|
const nextValue = active ? undefined : iconName;
|
|
form.setFieldValue("icon", nextValue);
|
|
if (nextValue) {
|
|
setIconSearchKeyword("");
|
|
setIconPickerOpen(false);
|
|
}
|
|
}}
|
|
aria-label={iconName}
|
|
style={{
|
|
height: 44,
|
|
width: "100%",
|
|
borderRadius: 10,
|
|
border: active ? "1px solid #1677ff" : "1px solid transparent",
|
|
background: active ? "#e6f4ff" : "#fff",
|
|
color: active ? "#1677ff" : "inherit",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
boxShadow: active ? "0 0 0 2px rgba(22,119,255,0.12)" : "none"
|
|
}}
|
|
>
|
|
{renderSelectableIcon(iconName)}
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
{!filteredIconOptions.length ? <div style={{ padding: "16px 0", textAlign: "center", color: "#8c8c8c" }}>{t("permissionsExt.iconEmpty")}</div> : null}
|
|
{visibleIconOptions.length < filteredIconOptions.length ? <div style={{ paddingTop: 12, textAlign: "center", color: "#8c8c8c", fontSize: 12 }}>{t("permissionsExt.iconLoadingMore", { current: visibleIconOptions.length, total: filteredIconOptions.length })}</div> : null}
|
|
</div></div> : null}
|
|
</Space>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item label={t("permissions.sort")} name="sortOrder" initialValue={0}>
|
|
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item label={t("permissions.isVisible")} name="isVisible" initialValue={1}>
|
|
<Select options={visibleDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item label={t("common.status")} name="status" initialValue={1}>
|
|
<Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item label={t("permissions.description")} name="description">
|
|
<Input.TextArea rows={2} placeholder={t("permissionsExt.descriptionPlaceholder")} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
} |