imeeting/frontend/src/pages/access/permissions/index.tsx

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