ソースを参照

feat: 优化页面渲染

BaiLuoYan 3 日 前
コミット
82daea8633

+ 71 - 49
src/pages/Admin/Product/Detail/tabs/RoleTab.tsx

@@ -11,6 +11,53 @@ import { Button, Popconfirm, Tag, message } from 'antd';
 import { useRef, useState } from 'react';
 import { RolePermDrawer } from './RolePermDrawer';
 
+interface RoleFormProps {
+  mode: EditorFormMode;
+  record?: API.RoleItem;
+  trigger: React.ReactElement;
+  productCode: string;
+  onSuccess: () => void;
+}
+
+const RoleForm = ({ mode, record, trigger, productCode, onSuccess }: RoleFormProps) => (
+  <DrawerForm
+    title={mode === 'edit' ? '编辑角色' : mode === 'copy' ? '复制角色' : '新建角色'}
+    trigger={trigger}
+    initialValues={
+      mode === 'copy' ? { ...record, id: undefined } : mode === 'edit' ? record : undefined
+    }
+    onFinish={async (values) => {
+      if (mode === 'edit' && record?.id) {
+        await fetchUpdateRole({
+          id: record.id,
+          name: values.name,
+          permsLevel: values.permsLevel,
+          remark: values.remark,
+        });
+      } else {
+        await fetchCreateRole({
+          productCode,
+          name: values.name,
+          permsLevel: values.permsLevel,
+          remark: values.remark,
+        });
+      }
+      onSuccess();
+      return true;
+    }}
+    drawerProps={{ destroyOnClose: true }}
+  >
+    <ProFormText name="name" label="角色名称" rules={[{ required: true }]} />
+    <ProFormDigit
+      name="permsLevel"
+      label="权限级别"
+      rules={[{ required: true }]}
+      fieldProps={{ precision: 0 }}
+    />
+    <ProFormText name="remark" label="备注" />
+  </DrawerForm>
+);
+
 interface RoleTabProps {
   productCode: string;
 }
@@ -22,52 +69,7 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
     roleId: 0,
   });
 
-  const RoleForm = ({
-    mode,
-    record,
-    trigger,
-  }: {
-    mode: EditorFormMode;
-    record?: API.RoleItem;
-    trigger: React.ReactElement;
-  }) => (
-    <DrawerForm
-      title={mode === 'edit' ? '编辑角色' : mode === 'copy' ? '复制角色' : '新建角色'}
-      trigger={trigger}
-      initialValues={
-        mode === 'copy' ? { ...record, id: undefined } : mode === 'edit' ? record : undefined
-      }
-      onFinish={async (values) => {
-        if (mode === 'edit' && record?.id) {
-          await fetchUpdateRole({
-            id: record.id,
-            name: values.name,
-            permsLevel: values.permsLevel,
-            remark: values.remark,
-          });
-        } else {
-          await fetchCreateRole({
-            productCode,
-            name: values.name,
-            permsLevel: values.permsLevel,
-            remark: values.remark,
-          });
-        }
-        actionRef.current?.reload();
-        return true;
-      }}
-      drawerProps={{ destroyOnClose: true }}
-    >
-      <ProFormText name="name" label="角色名称" rules={[{ required: true }]} />
-      <ProFormDigit
-        name="permsLevel"
-        label="权限级别"
-        rules={[{ required: true }]}
-        fieldProps={{ precision: 0 }}
-      />
-      <ProFormText name="remark" label="备注" />
-    </DrawerForm>
-  );
+  const reload = () => actionRef.current?.reload();
 
   const columns: ProColumns<API.RoleItem>[] = [
     { title: '名称', dataIndex: 'name' },
@@ -83,8 +85,22 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
       title: '操作',
       valueType: 'option',
       render: (_, r) => [
-        <RoleForm key="edit" mode="edit" record={r} trigger={<a>编辑</a>} />,
-        <RoleForm key="copy" mode="copy" record={r} trigger={<a>复制</a>} />,
+        <RoleForm
+          key="edit"
+          mode="edit"
+          record={r}
+          trigger={<a>编辑</a>}
+          productCode={productCode}
+          onSuccess={reload}
+        />,
+        <RoleForm
+          key="copy"
+          mode="copy"
+          record={r}
+          trigger={<a>复制</a>}
+          productCode={productCode}
+          onSuccess={reload}
+        />,
         <a key="perm" onClick={() => setPermDrawer({ open: true, roleId: r.id })}>
           绑定权限
         </a>,
@@ -114,7 +130,13 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
           return fetchRoleList({ ...params, productCode }, sorter, filter);
         }}
         toolBarRender={() => [
-          <RoleForm key="create" mode="add" trigger={<Button type="primary">新建角色</Button>} />,
+          <RoleForm
+            key="create"
+            mode="add"
+            trigger={<Button type="primary">新建角色</Button>}
+            productCode={productCode}
+            onSuccess={reload}
+          />,
         ]}
       />
       <RolePermDrawer

+ 26 - 0
src/pages/Admin/_shared/PermTree/components/PermList.tsx

@@ -0,0 +1,26 @@
+import { Checkbox } from 'antd';
+import { memo } from 'react';
+
+interface PermListProps {
+  perms: API.PermItem[];
+  checkedIds: Set<number>;
+  onToggle?: (p: API.PermItem) => void;
+  mode: 'view' | 'edit';
+}
+
+export const PermList = memo(({ perms, checkedIds, onToggle, mode }: PermListProps) => {
+  if (perms.length === 0) return null;
+  return (
+    <div className="flex flex-col gap-1">
+      {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-blue-500">{p.code}</span>
+          <span className="text-xs text-gray-500">{p.name}</span>
+        </label>
+      ))}
+    </div>
+  );
+});

+ 74 - 0
src/pages/Admin/_shared/PermTree/components/TabContent.tsx

@@ -0,0 +1,74 @@
+import { useIntl } from '@umijs/max';
+import { Empty } from 'antd';
+import { memo, useMemo } from 'react';
+import type { PermGroup } from '../lib/permUtils';
+import { PermList } from './PermList';
+
+interface TabContentProps {
+  tab: 'api' | 'data';
+  filteredGroups: PermGroup[];
+  allGroups: PermGroup[];
+  checkedIds: Set<number>;
+  mode: 'view' | 'edit';
+  onToggle?: (p: API.PermItem) => void;
+}
+
+interface DisplayGroup {
+  modelName: string;
+  perms: API.PermItem[];
+}
+
+export const TabContent = memo(
+  ({ tab, filteredGroups, allGroups, checkedIds, mode, onToggle }: TabContentProps) => {
+    const intl = useIntl();
+
+    const displayGroups = useMemo((): DisplayGroup[] => {
+      const source =
+        mode === 'view'
+          ? allGroups
+              .map((g) => ({
+                ...g,
+                apiPerms: g.apiPerms.filter((p) => checkedIds.has(p.id)),
+                dataPerms: g.dataPerms.filter((p) => checkedIds.has(p.id)),
+                fieldPerms: g.fieldPerms.filter((p) => checkedIds.has(p.id)),
+              }))
+              .filter(
+                (g) =>
+                  (tab === 'api' ? g.apiPerms.length : g.dataPerms.length + g.fieldPerms.length) >
+                  0,
+              )
+          : filteredGroups;
+
+      return source.reduce<DisplayGroup[]>((acc, g) => {
+        const perms = tab === 'api' ? g.apiPerms : [...g.dataPerms, ...g.fieldPerms];
+        if (perms.length > 0) acc.push({ modelName: g.modelName, perms });
+        return acc;
+      }, []);
+    }, [mode, allGroups, filteredGroups, checkedIds, tab]);
+
+    if (displayGroups.length === 0)
+      return (
+        <Empty description={intl.formatMessage({ id: 'admin.permTree.empty' })} className="py-8" />
+      );
+
+    return (
+      <div className="flex flex-col gap-3">
+        {displayGroups.map((g) => {
+          const owned = g.perms.filter((p) => checkedIds.has(p.id)).length;
+          return (
+            <div key={g.modelName} className="border border-gray-100 rounded p-3">
+              <div className="text-sm font-medium text-gray-700 mb-2">
+                {g.modelName}
+                <span className="text-xs text-gray-400 ml-1">
+                  ({owned}
+                  {mode === 'edit' ? `/${g.perms.length}` : ''})
+                </span>
+              </div>
+              <PermList perms={g.perms} checkedIds={checkedIds} onToggle={onToggle} mode={mode} />
+            </div>
+          );
+        })}
+      </div>
+    );
+  },
+);

+ 145 - 0
src/pages/Admin/_shared/PermTree/hooks/usePermTree.ts

@@ -0,0 +1,145 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { buildPairMap, getPairId, groupPerms, matchesPerm, sortPerms } from '../lib/permUtils';
+
+interface UsePermTreeOptions {
+  allPerms: API.PermItem[];
+  initialCheckedIds?: number[];
+  onChange?: (ids: number[]) => void;
+}
+
+export const usePermTree = ({ allPerms, initialCheckedIds = [], onChange }: UsePermTreeOptions) => {
+  const [keyword, setKeyword] = useState('');
+  const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set(initialCheckedIds));
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+
+  const sorted = useMemo(() => sortPerms(allPerms), [allPerms]);
+  const grouped = useMemo(() => groupPerms(sorted), [sorted]);
+  const pairMap = useMemo(() => buildPairMap(allPerms), [allPerms]);
+
+  const filteredGroups = useMemo(
+    () =>
+      grouped
+        .map((g) => ({
+          ...g,
+          apiPerms: g.apiPerms.filter((p) => matchesPerm(p, keyword)),
+          dataPerms: g.dataPerms.filter((p) => matchesPerm(p, keyword)),
+          fieldPerms: g.fieldPerms.filter((p) => matchesPerm(p, keyword)),
+        }))
+        .filter((g) => g.apiPerms.length + g.dataPerms.length + g.fieldPerms.length > 0),
+    [grouped, keyword],
+  );
+
+  const toggle = useCallback(
+    (perm: API.PermItem) => {
+      setCheckedIds((prev) => {
+        const next = new Set(prev);
+        if (next.has(perm.id)) {
+          next.delete(perm.id);
+          if (perm.code.startsWith('api:')) {
+            const pairId = getPairId(perm, pairMap);
+            if (pairId) next.delete(pairId);
+          }
+        } else {
+          next.add(perm.id);
+          if (perm.code.startsWith('data:') && perm.code.split(':').length === 3) {
+            const pairId = getPairId(perm, pairMap);
+            if (pairId) next.add(pairId);
+          }
+        }
+        onChangeRef.current?.([...next]);
+        return next;
+      });
+    },
+    [pairMap],
+  );
+
+  const getTabPerms = useCallback(
+    (tab: 'api' | 'data') =>
+      filteredGroups.flatMap((g) =>
+        tab === 'api' ? g.apiPerms : [...g.dataPerms, ...g.fieldPerms],
+      ),
+    [filteredGroups],
+  );
+
+  const selectAll = 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 pairId = getPairId(p, pairMap);
+              if (pairId) next.add(pairId);
+            }
+          });
+        }
+        onChangeRef.current?.([...next]);
+        return next;
+      });
+    },
+    [getTabPerms, pairMap],
+  );
+
+  const invert = useCallback(
+    (tab: 'api' | 'data') => {
+      const visible = getTabPerms(tab);
+      setCheckedIds((prev) => {
+        const next = new Set(prev);
+        visible.forEach((p) => {
+          if (next.has(p.id)) {
+            next.delete(p.id);
+            if (p.code.startsWith('api:')) {
+              const pairId = getPairId(p, pairMap);
+              if (pairId) next.delete(pairId);
+            }
+          } else {
+            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],
+  );
+
+  return {
+    keyword,
+    setKeyword,
+    checkedIds,
+    grouped,
+    filteredGroups,
+    toggle,
+    selectAll,
+    invert,
+    isAllSelected,
+    isIndeterminate,
+  };
+};

+ 4 - 91
src/pages/Admin/_shared/PermTree/index.tsx

@@ -1,9 +1,8 @@
-// web/src/pages/Admin/_shared/PermTree/index.tsx
 import { useIntl } from '@umijs/max';
-import { Checkbox, Empty, Input, Tabs } from 'antd';
+import { Checkbox, Input, Tabs } from 'antd';
 import { useState } from 'react';
-import { groupPerms, sortPerms } from './lib/permUtils';
-import { usePermTree } from './usePermTree';
+import { TabContent } from './components/TabContent';
+import { usePermTree } from './hooks/usePermTree';
 
 export interface PermTreeProps {
   mode: 'view' | 'edit';
@@ -12,98 +11,13 @@ export interface PermTreeProps {
   onChange?: (ids: number[]) => void;
 }
 
-const PermList = ({
-  perms,
-  checkedIds,
-  onToggle,
-  mode,
-}: {
-  perms: API.PermItem[];
-  checkedIds: Set<number>;
-  onToggle?: (p: API.PermItem) => void;
-  mode: 'view' | 'edit';
-}) => {
-  if (perms.length === 0) return null;
-  return (
-    <div className="flex flex-col gap-1">
-      {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-blue-500">{p.code}</span>
-          <span className="text-xs text-gray-500">{p.name}</span>
-        </label>
-      ))}
-    </div>
-  );
-};
-
-const TabContent = ({
-  tab,
-  filteredGroups,
-  allGroups,
-  checkedIds,
-  mode,
-  onToggle,
-}: {
-  tab: 'api' | 'data';
-  filteredGroups: ReturnType<typeof groupPerms>;
-  allGroups: ReturnType<typeof groupPerms>;
-  checkedIds: Set<number>;
-  mode: 'view' | 'edit';
-  onToggle?: (p: API.PermItem) => void;
-}) => {
-  const intl = useIntl();
-  const groups =
-    mode === 'view'
-      ? allGroups
-          .map((g) => ({
-            ...g,
-            apiPerms: g.apiPerms.filter((p) => checkedIds.has(p.id)),
-            dataPerms: g.dataPerms.filter((p) => checkedIds.has(p.id)),
-            fieldPerms: g.fieldPerms.filter((p) => checkedIds.has(p.id)),
-          }))
-          .filter(
-            (g) =>
-              (tab === 'api' ? g.apiPerms.length : g.dataPerms.length + g.fieldPerms.length) > 0,
-          )
-      : filteredGroups;
-
-  if (groups.length === 0)
-    return (
-      <Empty description={intl.formatMessage({ id: 'admin.permTree.empty' })} className="py-8" />
-    );
-
-  return (
-    <div className="flex flex-col gap-3">
-      {groups.map((g) => {
-        const perms = tab === 'api' ? g.apiPerms : [...g.dataPerms, ...g.fieldPerms];
-        if (perms.length === 0) return null;
-        const owned = perms.filter((p) => checkedIds.has(p.id)).length;
-        return (
-          <div key={g.modelName} className="border border-gray-100 rounded p-3">
-            <div className="text-sm font-medium text-gray-700 mb-2">
-              {g.modelName}
-              <span className="text-xs text-gray-400 ml-1">
-                ({owned}
-                {mode === 'edit' ? `/${perms.length}` : ''})
-              </span>
-            </div>
-            <PermList perms={perms} checkedIds={checkedIds} onToggle={onToggle} mode={mode} />
-          </div>
-        );
-      })}
-    </div>
-  );
-};
-
 export const PermTree = ({ mode, allPerms, checkedPermIds = [], onChange }: PermTreeProps) => {
   const intl = useIntl();
   const {
     keyword,
     setKeyword,
     checkedIds,
+    grouped: allGroups,
     filteredGroups,
     toggle,
     selectAll,
@@ -112,7 +26,6 @@ export const PermTree = ({ mode, allPerms, checkedPermIds = [], onChange }: Perm
     isIndeterminate,
   } = usePermTree({ allPerms, initialCheckedIds: checkedPermIds, onChange });
 
-  const allGroups = groupPerms(sortPerms(allPerms));
   const [activeTab, setActiveTab] = useState<'api' | 'data'>('api');
 
   const tabItems = [

+ 33 - 16
src/pages/Admin/_shared/PermTree/lib/permUtils.ts

@@ -4,8 +4,8 @@ import { pinyin } from 'pinyin-pro';
 export interface PermGroup {
   modelName: string;
   apiPerms: API.PermItem[];
-  dataPerms: API.PermItem[]; // data:model:action(非字段)
-  fieldPerms: API.PermItem[]; // data:model:field:read|write
+  dataPerms: API.PermItem[];
+  fieldPerms: API.PermItem[];
 }
 
 // ---- 排序 ----
@@ -27,7 +27,7 @@ export const groupPerms = (perms: API.PermItem[]): PermGroup[] => {
   const map = new Map<string, PermGroup>();
   for (const p of perms) {
     const parts = p.code.split(':');
-    const type = parts[0]; // api | data
+    const type = parts[0];
     const model = parts[1] ?? 'unknown';
     if (!map.has(model)) {
       map.set(model, { modelName: model, apiPerms: [], dataPerms: [], fieldPerms: [] });
@@ -44,20 +44,30 @@ export const groupPerms = (perms: API.PermItem[]): PermGroup[] => {
   return [...map.values()].sort((a, b) => a.modelName.localeCompare(b.modelName));
 };
 
-// ---- 搜索 ----
-const getPinyinStr = (text: string) => pinyin(text, { toneType: 'none', type: 'array' }).join('');
+// ---- 搜索(缓存拼音结果) ----
+const pinyinCache = new Map<string, { full: string; initials: string }>();
 
-const getPinyinInitials = (text: string) =>
-  pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('');
+const getPermPinyin = (name: string) => {
+  let cached = pinyinCache.get(name);
+  if (!cached) {
+    cached = {
+      full: pinyin(name, { toneType: 'none', type: 'array' }).join('').toLowerCase(),
+      initials: pinyin(name, { pattern: 'first', toneType: 'none', type: 'array' })
+        .join('')
+        .toLowerCase(),
+    };
+    pinyinCache.set(name, cached);
+  }
+  return cached;
+};
 
 export const matchesPerm = (perm: API.PermItem, keyword: string): boolean => {
   if (!keyword) return true;
   const k = keyword.toLowerCase();
-  const code = perm.code.toLowerCase();
-  const name = perm.name.toLowerCase();
-  const namePy = getPinyinStr(perm.name).toLowerCase();
-  const nameInitials = getPinyinInitials(perm.name).toLowerCase();
-  return code.includes(k) || name.includes(k) || namePy.includes(k) || nameInitials.includes(k);
+  if (perm.code.toLowerCase().includes(k)) return true;
+  if (perm.name.toLowerCase().includes(k)) return true;
+  const py = getPermPinyin(perm.name);
+  return py.full.includes(k) || py.initials.includes(k);
 };
 
 // ---- 用户权限 delta 计算 ----
@@ -77,12 +87,19 @@ export const calcUserPermDelta = (
   ];
 };
 
-// ---- 获取权限对应的 pair id ----
-// api:model:action <-> data:model:action 互查
-export const getPairId = (perm: API.PermItem, allPerms: API.PermItem[]): number | undefined => {
+// ---- pair 查找(Map 预构建,O(1) 查找) ----
+export const buildPairMap = (allPerms: API.PermItem[]): Map<string, number> => {
+  const map = new Map<string, number>();
+  for (const p of allPerms) {
+    map.set(p.code, p.id);
+  }
+  return map;
+};
+
+export const getPairId = (perm: API.PermItem, pairMap: Map<string, number>): number | undefined => {
   const parts = perm.code.split(':');
   if (parts.length !== 3) return undefined;
   const [type, model, action] = parts;
   const pairType = type === 'api' ? 'data' : 'api';
-  return allPerms.find((p) => p.code === `${pairType}:${model}:${action}`)?.id;
+  return pairMap.get(`${pairType}:${model}:${action}`);
 };

+ 0 - 121
src/pages/Admin/_shared/PermTree/usePermTree.ts

@@ -1,121 +0,0 @@
-import { useMemo, useState } from 'react';
-import { getPairId, groupPerms, matchesPerm, sortPerms } from './lib/permUtils';
-
-interface UsePermTreeOptions {
-  allPerms: API.PermItem[];
-  initialCheckedIds?: number[];
-  onChange?: (ids: number[]) => void;
-}
-
-export const usePermTree = ({ allPerms, initialCheckedIds = [], onChange }: UsePermTreeOptions) => {
-  const [keyword, setKeyword] = useState('');
-  const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set(initialCheckedIds));
-
-  const sorted = useMemo(() => sortPerms(allPerms), [allPerms]);
-  const grouped = useMemo(() => groupPerms(sorted), [sorted]);
-
-  const filteredGroups = useMemo(
-    () =>
-      grouped
-        .map((g) => ({
-          ...g,
-          apiPerms: g.apiPerms.filter((p) => matchesPerm(p, keyword)),
-          dataPerms: g.dataPerms.filter((p) => matchesPerm(p, keyword)),
-          fieldPerms: g.fieldPerms.filter((p) => matchesPerm(p, keyword)),
-        }))
-        .filter((g) => g.apiPerms.length + g.dataPerms.length + g.fieldPerms.length > 0),
-    [grouped, keyword],
-  );
-
-  const commit = (next: Set<number>) => {
-    setCheckedIds(next);
-    onChange?.([...next]);
-  };
-
-  const toggle = (perm: API.PermItem) => {
-    const next = new Set(checkedIds);
-    if (next.has(perm.id)) {
-      // 取消 api → 联动取消对应 data
-      next.delete(perm.id);
-      if (perm.code.startsWith('api:')) {
-        const pairId = getPairId(perm, allPerms);
-        if (pairId) next.delete(pairId);
-      }
-    } else {
-      // 勾选 data → 联动勾选对应 api
-      next.add(perm.id);
-      if (perm.code.startsWith('data:') && perm.code.split(':').length === 3) {
-        const pairId = getPairId(perm, allPerms);
-        if (pairId) next.add(pairId);
-      }
-    }
-    commit(next);
-  };
-
-  // 当前 Tab 可见权限(api tab 或 data tab)
-  const getTabPerms = (tab: 'api' | 'data') =>
-    filteredGroups.flatMap((g) => (tab === 'api' ? g.apiPerms : [...g.dataPerms, ...g.fieldPerms]));
-
-  const selectAll = (tab: 'api' | 'data') => {
-    const visible = getTabPerms(tab);
-    const allChecked = visible.every((p) => checkedIds.has(p.id));
-    const next = new Set(checkedIds);
-    if (allChecked) {
-      visible.forEach((p) => next.delete(p.id));
-    } else {
-      visible.forEach((p) => {
-        next.add(p.id);
-        // 勾选 data 联动 api
-        if (p.code.startsWith('data:') && p.code.split(':').length === 3) {
-          const pairId = getPairId(p, allPerms);
-          if (pairId) next.add(pairId);
-        }
-      });
-    }
-    commit(next);
-  };
-
-  const invert = (tab: 'api' | 'data') => {
-    const visible = getTabPerms(tab);
-    const next = new Set(checkedIds);
-    visible.forEach((p) => {
-      if (next.has(p.id)) {
-        next.delete(p.id);
-        if (p.code.startsWith('api:')) {
-          const pairId = getPairId(p, allPerms);
-          if (pairId) next.delete(pairId);
-        }
-      } else {
-        next.add(p.id);
-        if (p.code.startsWith('data:') && p.code.split(':').length === 3) {
-          const pairId = getPairId(p, allPerms);
-          if (pairId) next.add(pairId);
-        }
-      }
-    });
-    commit(next);
-  };
-
-  const isAllSelected = (tab: 'api' | 'data') => {
-    const visible = getTabPerms(tab);
-    return visible.length > 0 && visible.every((p) => checkedIds.has(p.id));
-  };
-
-  const isIndeterminate = (tab: 'api' | 'data') => {
-    const visible = getTabPerms(tab);
-    const count = visible.filter((p) => checkedIds.has(p.id)).length;
-    return count > 0 && count < visible.length;
-  };
-
-  return {
-    keyword,
-    setKeyword,
-    checkedIds,
-    filteredGroups,
-    toggle,
-    selectAll,
-    invert,
-    isAllSelected,
-    isIndeterminate,
-  };
-};