feat: generic configActions plugin slot for api-keys & model-routes

Introduce `pages/api-keys/plugin.tsx` and `pages/model-routes/plugin.tsx`
defining a unified `{ key, labelId, icon, priority, danger, form,
useCreate }` contract that plugins use to contribute per-row
"configure this record" actions. The host renders one dropdown entry
per registered action and mounts each entry's form, ordered by a
single numeric priority (built-ins are ranked on the same scale; rows
flagged `danger` sink to the bottom).

Drops the older `apiKeys.rowActions` / `modelRoutes.rowActions` slots
and the implicit drawer half of
`<PluginExtraFields name="APIKeysPageGlobal" />`. The model-routes
mount point stays for the page-level quota-defaults bulk-fetch and now
also accepts a `refreshToken` so per-row saves can invalidate derived
page data without changing the row set.
This commit is contained in:
Yuxing Deng
2026-05-18 12:14:19 +08:00
committed by jialin
parent 38b91a558b
commit ae8ec8572c
6 changed files with 535 additions and 58 deletions
+95 -3
View File
@@ -1,5 +1,6 @@
import { expandKeysAtom } from '@/atoms/clusters';
import { registerRouteConfigAtom } from '@/atoms/routes';
import PluginExtraFields from '@/components/plugin-extra-fields';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
@@ -20,7 +21,7 @@ import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import { useAtom } from 'jotai';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PageBox from '../_components/page-box';
import { queryModelsList } from '../llmodels/apis';
import AccessControlModal from '../llmodels/components/access-control-modal';
@@ -44,8 +45,39 @@ import useOpenPlayground from './hooks/use-open-playground';
import useRoutesColumns from './hooks/use-routes-columns';
import useTargetSourceModels from './hooks/use-target-source-models';
import useViewApIInfo from './hooks/use-view-api-info';
import {
ModelRouteConfigActionMount,
getModelRouteConfigActions,
type ModelRouteConfigActionController
} from './plugin';
const ModelRoutes: React.FC = () => {
// Single source of truth for plugin data lifecycle. Every successful
// table fetch updates this atomically: `routeIds` mirrors the rows
// currently visible (so plugins can bulk-fetch per-row data without
// N round-trips), and `refreshToken` bumps so plugins refetch even
// when the id set is unchanged — e.g. an in-place save from the
// quota-limit drawer leaves the row set intact but invalidates the
// derived defaults map.
const [pluginContext, setPluginContext] = useState<{
routeIds: number[];
refreshToken: number;
}>({
routeIds: [],
refreshToken: 0
});
// Wraps `queryModelRoutes` so the plugin context updates in lockstep
// with the table data — no separate "bump after save" signal needed.
const fetchAPI = useMemoizedFn(async (params: any, options?: any) => {
const res = await queryModelRoutes(params, options);
setPluginContext((prev) => ({
routeIds: (res.items ?? []).map((r: ListItem) => r.id),
refreshToken: prev.refreshToken + 1
}));
return res;
});
const {
dataSource,
rowSelection,
@@ -60,7 +92,7 @@ const ModelRoutes: React.FC = () => {
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Routes,
fetchAPI: queryModelRoutes,
fetchAPI,
deleteAPI: deleteModelRoute,
watch: true,
API: MODEL_ROUTES,
@@ -276,7 +308,46 @@ const ModelRoutes: React.FC = () => {
}
}, [registerRouteConfig, dataSource.loadend]);
const columns = useRoutesColumns(handleSelect);
// Generic per-row plugin slot. Each enterprise plugin contributes a
// `{ key, labelId, icon, priority, form, useCreate }` entry; the host
// renders a button per entry in the dropdown and renders one
// `ModelRouteConfigActionMount` per entry — those mounts own each
// entry's controller and register it back into `controllersRef` so
// dropdown clicks can route to the correct `openModal`. See
// `./plugin.tsx`.
//
// The action list is read once. Plugins are registered at boot and
// never recompute, so the reference is stable for the lifetime of
// the page and `useMemo([])` is safe.
const configActions = useMemo(() => getModelRouteConfigActions(), []);
const controllersRef = useRef<
Record<string, ModelRouteConfigActionController>
>({});
const registerController = useCallback(
(key: string, controller: ModelRouteConfigActionController) => {
controllersRef.current[key] = controller;
},
[]
);
const handleConfigAction = useMemoizedFn(
(actionKey: string, record: ListItem) => {
controllersRef.current[actionKey]?.openModal(record);
}
);
// Per-row save closes the drawer and refetches the table. The wrapped
// `fetchAPI` above takes care of bumping `pluginContext.refreshToken`
// for derived plugin data.
const handleConfigActionOk = useMemoizedFn(() => {
fetchData();
});
const columns = useRoutesColumns({
handleSelect,
configActions,
onConfigAction: handleConfigAction
});
return (
<>
@@ -368,6 +439,27 @@ const ModelRoutes: React.FC = () => {
onClose={closeViewAPIInfo}
></APIAccessInfoModal>
<DeleteModal ref={modalRef}></DeleteModal>
{/* One mount per registered action. Each mount calls its
entry's `useCreate` (single hook per component, so iterating
the plugin list doesn't violate the Rules of Hooks),
renders the form, and registers its controller so dropdown
clicks can dispatch to it. */}
{configActions.map((action) => (
<ModelRouteConfigActionMount
key={action.key}
action={action}
registerController={registerController}
onOk={handleConfigActionOk}
/>
))}
{/* Page-level data lifecycle for plugin-contributed extra
columns. Receives the current list of visible route ids so
the plugin can bulk-fetch their per-user defaults in one
call (used by the quota-default column cells); `refreshToken`
bumps after a per-row save so derived page data refetches
even when the row set is unchanged. Renders nothing when no
plugin is registered. */}
<PluginExtraFields name="ModelRoutesPageGlobal" context={pluginContext} />
</>
);
};