ソースを参照

perf: 权限管理组件优化

BaiLuoYan 1 日 前
コミット
741c159b0d

+ 5 - 3
src/pages/Admin/_shared/PermTree/components/PermList.tsx

@@ -11,14 +11,16 @@ interface PermListProps {
 export const PermList = memo(({ perms, checkedIds, onToggle, mode }: PermListProps) => {
   if (perms.length === 0) return null;
   return (
-    <div className="flex flex-col gap-1">
+    <div className="flex flex-col gap-1 ps-6">
       {perms.map((p) => (
         <label key={p.id} className="flex items-center gap-2 cursor-pointer py-0.5">
           {mode === 'edit' && (
             <Checkbox checked={checkedIds.has(p.id)} onChange={() => onToggle?.(p)} />
           )}
-          <span className="text-xs font-mono text-(--ant-color-primary)">{p.code}</span>
-          <span className="text-xs text-(--ant-color-text-tertiary)">{p.name}</span>
+          <span className="text-xs">
+            {p.name}
+            <span className="font-mono text-(--ant-color-text-quaternary) ml-1">({p.code})</span>
+          </span>
         </label>
       ))}
     </div>

+ 73 - 9
src/pages/Admin/_shared/PermTree/components/TabContent.tsx

@@ -1,6 +1,7 @@
+import { CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons';
 import { useIntl } from '@umijs/max';
-import { Empty } from 'antd';
-import { memo, useMemo } from 'react';
+import { Checkbox, Empty } from 'antd';
+import { memo, useEffect, useMemo, useState } from 'react';
 import type { PermGroup } from '../lib/permUtils';
 import { PermList } from './PermList';
 
@@ -11,6 +12,9 @@ interface TabContentProps {
   checkedIds: Set<number>;
   mode: 'view' | 'edit';
   onToggle?: (p: API.PermItem) => void;
+  onToggleGroup?: (perms: API.PermItem[]) => void;
+  defaultExpanded: boolean;
+  expandKey: number;
 }
 
 interface DisplayGroup {
@@ -19,8 +23,19 @@ interface DisplayGroup {
 }
 
 export const TabContent = memo(
-  ({ tab, filteredGroups, allGroups, checkedIds, mode, onToggle }: TabContentProps) => {
+  ({
+    tab,
+    filteredGroups,
+    allGroups,
+    checkedIds,
+    mode,
+    onToggle,
+    onToggleGroup,
+    defaultExpanded,
+    expandKey,
+  }: TabContentProps) => {
     const intl = useIntl();
+    const [collapsedModels, setCollapsedModels] = useState<Set<string>>(new Set());
 
     const displayGroups = useMemo((): DisplayGroup[] => {
       const source =
@@ -46,6 +61,26 @@ export const TabContent = memo(
       }, []);
     }, [mode, allGroups, filteredGroups, checkedIds, tab]);
 
+    useEffect(() => {
+      if (defaultExpanded) {
+        setCollapsedModels(new Set());
+      } else {
+        setCollapsedModels(new Set(displayGroups.map((g) => g.modelName)));
+      }
+    }, [expandKey]);
+
+    const toggleModel = (modelName: string) => {
+      setCollapsedModels((prev) => {
+        const next = new Set(prev);
+        if (next.has(modelName)) {
+          next.delete(modelName);
+        } else {
+          next.add(modelName);
+        }
+        return next;
+      });
+    };
+
     if (displayGroups.length === 0)
       return (
         <Empty description={intl.formatMessage({ id: 'admin.permTree.empty' })} className="py-8" />
@@ -55,19 +90,48 @@ export const TabContent = memo(
       <div className="flex flex-col gap-3">
         {displayGroups.map((g) => {
           const owned = g.perms.filter((p) => checkedIds.has(p.id)).length;
+          const collapsed = collapsedModels.has(g.modelName);
+          const allChecked = owned === g.perms.length;
+          const indeterminate = owned > 0 && !allChecked;
           return (
             <div
               key={g.modelName}
               className="border border-(--ant-color-border-secondary) rounded p-3"
             >
-              <div className="text-sm font-medium text-(--ant-color-text-secondary) mb-2">
-                {g.modelName}
-                <span className="text-xs text-(--ant-color-text-quaternary) ml-1">
-                  ({owned}
-                  {mode === 'edit' ? `/${g.perms.length}` : ''})
+              <div className="text-sm font-medium text-(--ant-color-text-secondary) flex items-center gap-1.5">
+                {mode === 'edit' && (
+                  <Checkbox
+                    checked={allChecked}
+                    indeterminate={indeterminate}
+                    onChange={() => onToggleGroup?.(g.perms)}
+                  />
+                )}
+                <span
+                  className="cursor-pointer select-none flex items-center flex-1"
+                  onClick={() => toggleModel(g.modelName)}
+                >
+                  {collapsed ? (
+                    <CaretRightOutlined className="text-xs me-1" />
+                  ) : (
+                    <CaretDownOutlined className="text-xs me-1" />
+                  )}
+                  {g.modelName}
+                  <span className="text-xs text-(--ant-color-text-quaternary) ms-1">
+                    ({owned}
+                    {mode === 'edit' ? `/${g.perms.length}` : ''})
+                  </span>
                 </span>
               </div>
-              <PermList perms={g.perms} checkedIds={checkedIds} onToggle={onToggle} mode={mode} />
+              {!collapsed && (
+                <div className="mt-2">
+                  <PermList
+                    perms={g.perms}
+                    checkedIds={checkedIds}
+                    onToggle={onToggle}
+                    mode={mode}
+                  />
+                </div>
+              )}
             </div>
           );
         })}

+ 56 - 45
src/pages/Admin/_shared/PermTree/hooks/usePermTree.ts

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { buildPairMap, getPairId, groupPerms, matchesPerm, sortPerms } from '../lib/permUtils';
 
 interface UsePermTreeOptions {
@@ -13,6 +13,11 @@ export const usePermTree = ({ allPerms, initialCheckedIds = [], onChange }: UseP
   const onChangeRef = useRef(onChange);
   onChangeRef.current = onChange;
 
+  const checkedKey = initialCheckedIds.join(',');
+  useEffect(() => {
+    setCheckedIds(new Set(initialCheckedIds));
+  }, [checkedKey]);
+
   const sorted = useMemo(() => sortPerms(allPerms), [allPerms]);
   const grouped = useMemo(() => groupPerms(sorted), [sorted]);
   const pairMap = useMemo(() => buildPairMap(allPerms), [allPerms]);
@@ -54,80 +59,85 @@ export const usePermTree = ({ allPerms, initialCheckedIds = [], onChange }: UseP
     [pairMap],
   );
 
-  const getTabPerms = useCallback(
-    (tab: 'api' | 'data') =>
-      filteredGroups.flatMap((g) =>
-        tab === 'api' ? g.apiPerms : [...g.dataPerms, ...g.fieldPerms],
-      ),
-    [filteredGroups],
-  );
+  const selectAll = useCallback(() => {
+    setCheckedIds((prev) => {
+      const allChecked = sorted.every((p) => prev.has(p.id));
+      const next = new Set(prev);
+      if (allChecked) {
+        sorted.forEach((p) => next.delete(p.id));
+      } else {
+        sorted.forEach((p) => next.add(p.id));
+      }
+      onChangeRef.current?.([...next]);
+      return next;
+    });
+  }, [sorted]);
 
-  const selectAll = useCallback(
+  const invert = useCallback(
     (tab: 'api' | 'data') => {
-      const visible = getTabPerms(tab);
       setCheckedIds((prev) => {
-        const allChecked = visible.every((p) => prev.has(p.id));
-        const next = new Set(prev);
-        if (allChecked) {
-          visible.forEach((p) => next.delete(p.id));
-        } else {
-          visible.forEach((p) => {
-            next.add(p.id);
-            if (p.code.startsWith('data:') && p.code.split(':').length === 3) {
+        const next = new Set(sorted.map((p) => p.id).filter((id) => !prev.has(id)));
+        if (tab === 'data') {
+          // 补选所有选中的 data:model:action 对应的 api 配对
+          sorted.forEach((p) => {
+            if (next.has(p.id) && p.code.startsWith('data:') && p.code.split(':').length === 3) {
               const pairId = getPairId(p, pairMap);
               if (pairId) next.add(pairId);
             }
           });
+        } else {
+          // 取消所有未选中的 api:model:action 对应的 data 配对
+          sorted.forEach((p) => {
+            if (!next.has(p.id) && p.code.startsWith('api:')) {
+              const pairId = getPairId(p, pairMap);
+              if (pairId) next.delete(pairId);
+            }
+          });
         }
         onChangeRef.current?.([...next]);
         return next;
       });
     },
-    [getTabPerms, pairMap],
+    [sorted, pairMap],
   );
 
-  const invert = useCallback(
-    (tab: 'api' | 'data') => {
-      const visible = getTabPerms(tab);
+  const isAllSelected = useMemo(
+    () => sorted.length > 0 && sorted.every((p) => checkedIds.has(p.id)),
+    [sorted, checkedIds],
+  );
+
+  const isIndeterminate = useMemo(() => {
+    const count = sorted.filter((p) => checkedIds.has(p.id)).length;
+    return count > 0 && count < sorted.length;
+  }, [sorted, checkedIds]);
+
+  const toggleGroup = useCallback(
+    (perms: API.PermItem[]) => {
       setCheckedIds((prev) => {
+        const allChecked = perms.every((p) => prev.has(p.id));
         const next = new Set(prev);
-        visible.forEach((p) => {
-          if (next.has(p.id)) {
+        if (allChecked) {
+          perms.forEach((p) => {
             next.delete(p.id);
             if (p.code.startsWith('api:')) {
               const pairId = getPairId(p, pairMap);
               if (pairId) next.delete(pairId);
             }
-          } else {
+          });
+        } else {
+          perms.forEach((p) => {
             next.add(p.id);
             if (p.code.startsWith('data:') && p.code.split(':').length === 3) {
               const pairId = getPairId(p, pairMap);
               if (pairId) next.add(pairId);
             }
-          }
-        });
+          });
+        }
         onChangeRef.current?.([...next]);
         return next;
       });
     },
-    [getTabPerms, pairMap],
-  );
-
-  const isAllSelected = useCallback(
-    (tab: 'api' | 'data') => {
-      const visible = getTabPerms(tab);
-      return visible.length > 0 && visible.every((p) => checkedIds.has(p.id));
-    },
-    [getTabPerms, checkedIds],
-  );
-
-  const isIndeterminate = useCallback(
-    (tab: 'api' | 'data') => {
-      const visible = getTabPerms(tab);
-      const count = visible.filter((p) => checkedIds.has(p.id)).length;
-      return count > 0 && count < visible.length;
-    },
-    [getTabPerms, checkedIds],
+    [pairMap],
   );
 
   return {
@@ -137,6 +147,7 @@ export const usePermTree = ({ allPerms, initialCheckedIds = [], onChange }: UseP
     grouped,
     filteredGroups,
     toggle,
+    toggleGroup,
     selectAll,
     invert,
     isAllSelected,

+ 49 - 35
src/pages/Admin/_shared/PermTree/index.tsx

@@ -1,6 +1,6 @@
 import { useIntl } from '@umijs/max';
 import { Checkbox, Input, Tabs } from 'antd';
-import { useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { TabContent } from './components/TabContent';
 import { usePermTree } from './hooks/usePermTree';
 
@@ -11,7 +11,7 @@ export interface PermTreeProps {
   onChange?: (ids: number[]) => void;
 }
 
-const TAB_KEYS = ['api', 'data'] as const;
+const TAB_KEYS = ['data', 'api'] as const;
 
 export const PermTree = ({ mode, allPerms, checkedPermIds = [], onChange }: PermTreeProps) => {
   const intl = useIntl();
@@ -22,61 +22,75 @@ export const PermTree = ({ mode, allPerms, checkedPermIds = [], onChange }: Perm
     grouped: allGroups,
     filteredGroups,
     toggle,
+    toggleGroup,
     selectAll,
     invert,
     isAllSelected,
     isIndeterminate,
   } = usePermTree({ allPerms, initialCheckedIds: checkedPermIds, onChange });
 
-  const [activeTab, setActiveTab] = useState<'api' | 'data'>('api');
+  const [activeTab, setActiveTab] = useState<'api' | 'data'>('data');
+  const [allExpanded, setAllExpanded] = useState(true);
+  const [expandKey, setExpandKey] = useState(0);
+
+  const toggleAllExpanded = useCallback(() => {
+    setAllExpanded((prev) => !prev);
+    setExpandKey((k) => k + 1);
+  }, []);
 
   const tabItems = useMemo(
     () =>
       TAB_KEYS.map((key) => ({
         key,
         label: intl.formatMessage({ id: `admin.permTree.${key}Tab` }),
-        children: (
-          <TabContent
-            tab={key}
-            filteredGroups={filteredGroups}
-            allGroups={allGroups}
-            checkedIds={checkedIds}
-            mode={mode}
-            onToggle={toggle}
-          />
-        ),
       })),
-    [intl, filteredGroups, allGroups, checkedIds, mode, toggle],
+    [intl],
   );
 
   return (
     <div className="flex flex-col gap-2">
-      {mode === 'edit' && (
-        <div className="flex items-center gap-2">
-          <Input.Search
-            placeholder={intl.formatMessage({ id: 'admin.permTree.search' })}
-            value={keyword}
-            onChange={(e) => setKeyword(e.target.value)}
-            allowClear
-            className="flex-1"
-          />
-          <Checkbox
-            checked={isAllSelected(activeTab)}
-            indeterminate={isIndeterminate(activeTab)}
-            onChange={() => selectAll(activeTab)}
-          >
-            {intl.formatMessage({ id: 'admin.permTree.selectAll' })}
-          </Checkbox>
-          <a onClick={() => invert(activeTab)} className="text-sm whitespace-nowrap">
-            {intl.formatMessage({ id: 'admin.permTree.invert' })}
-          </a>
-        </div>
-      )}
+      <div className="flex items-center gap-2">
+        {mode === 'edit' && (
+          <>
+            <Input.Search
+              placeholder={intl.formatMessage({ id: 'admin.permTree.search' })}
+              value={keyword}
+              onChange={(e) => setKeyword(e.target.value)}
+              allowClear
+              className="flex-1"
+            />
+            <Checkbox
+              checked={isAllSelected}
+              indeterminate={isIndeterminate}
+              onChange={() => selectAll()}
+            >
+              {intl.formatMessage({ id: 'admin.permTree.selectAll' })}
+            </Checkbox>
+            <a onClick={() => invert(activeTab)} className="text-sm whitespace-nowrap">
+              {intl.formatMessage({ id: 'admin.permTree.invert' })}
+            </a>
+          </>
+        )}
+        <a onClick={toggleAllExpanded} className="text-sm whitespace-nowrap ml-auto">
+          {allExpanded ? '折叠全部' : '展开全部'}
+        </a>
+      </div>
       <Tabs
         activeKey={activeTab}
         onChange={(k) => setActiveTab(k as 'api' | 'data')}
         items={tabItems}
       />
+      <TabContent
+        tab={activeTab}
+        filteredGroups={filteredGroups}
+        allGroups={allGroups}
+        checkedIds={checkedIds}
+        mode={mode}
+        onToggle={toggle}
+        onToggleGroup={toggleGroup}
+        defaultExpanded={allExpanded}
+        expandKey={expandKey}
+      />
     </div>
   );
 };

+ 2 - 2
src/pages/Admin/_shared/UserPermDrawer.tsx

@@ -55,7 +55,7 @@ export const UserPermDrawer = ({
       const [userRes, permRes, userPermsRes] = await Promise.all([
         fetchUserDetail({ id: userId, productCode }),
         fetchPermList({ productCode, pageSize: 9999 }),
-        fetchGetUserPerms({ userId }),
+        fetchGetUserPerms({ userId, productCode }),
       ]);
       const perms = permRes.data ?? [];
       setAllPerms(perms);
@@ -106,7 +106,7 @@ export const UserPermDrawer = ({
     setSaving(true);
     try {
       const perms = calcUserPermDelta(checkedIds, roleInheritedIds);
-      const res = await fetchSetUserPerms({ userId, perms });
+      const res = await fetchSetUserPerms({ userId, productCode, perms });
       if (!res.success) return;
       message.success('保存成功');
       onClose();

+ 2 - 0
src/services/user/typings.d.ts

@@ -74,10 +74,12 @@ declare namespace API {
   }
   interface SetUserPermsReq {
     userId: number;
+    productCode?: string;
     perms: UserPermItem[];
   }
   interface GetUserPermsReq {
     userId: number;
+    productCode: string;
   }
   interface GetUserPermsResp {
     perms: UserPermItem[];