feat(identity): 支持用户和用户组批量管理

增加原子批量启用、禁用和删除接口及管理端多选操作,目标缺失时整批回滚。\n\n拆分管理端与 API Key 权限缓存并在弹窗保存后刷新候选;补齐失效规则一键清理样式、固定右侧操作列和 OpenAPI 契约。
This commit is contained in:
2026-08-03 15:43:49 +08:00
parent 7376d6fab6
commit ad8cdd525b
19 changed files with 1167 additions and 74 deletions
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { dataKeysForRoute } from './App';
describe('access-rule route data isolation', () => {
it('uses independent cache keys for workspace and admin access rules', () => {
expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).toContain('apiKeyAccessRules');
expect(dataKeysForRoute('workspace', 'overview', 'apiKeys', true)).not.toContain('adminAccessRules');
expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).toContain('adminAccessRules');
expect(dataKeysForRoute('admin', 'userGroups', 'overview', true)).not.toContain('apiKeyAccessRules');
});
});
+89 -21
View File
@@ -24,6 +24,7 @@ import type {
GatewayUser,
GatewayWalletAccount,
GatewayWalletTransaction,
IdentityBatchAction,
IntegrationPlatform,
ModelCatalogResponse,
ModelRateLimitStatus,
@@ -42,6 +43,8 @@ import type {
import {
batchAccessRules,
batchApiKeyAccessRules,
batchGatewayUsers,
batchUserGroups,
createAccessRule,
createApiKey,
createFileStorageChannel,
@@ -179,7 +182,7 @@ import type {
WorkspaceSection,
} from './types';
type DataKey =
export type DataKey =
| 'health'
| 'currentUser'
| 'currentUserGroups'
@@ -210,7 +213,8 @@ type DataKey =
| 'tasks'
| 'wallet'
| 'walletTransactions'
| 'accessRules'
| 'adminAccessRules'
| 'apiKeyAccessRules'
| 'auditLogs'
| 'apiKeys';
@@ -251,7 +255,8 @@ export function App() {
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
const [runnerPolicy, setRunnerPolicy] = useState<GatewayRunnerPolicy | null>(null);
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
const [adminAccessRules, setAdminAccessRules] = useState<GatewayAccessRule[]>([]);
const [apiKeyAccessRules, setApiKeyAccessRules] = useState<GatewayAccessRule[]>([]);
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [modelRateLimits, setModelRateLimits] = useState<ModelRateLimitStatus[]>([]);
@@ -424,13 +429,13 @@ export function App() {
{ label: 'Provider', value: activeProviders || providers.length, tone: 'amber' },
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
{ label: '运行策略', value: runtimePolicySets.length, tone: 'slate' },
{ label: '访问规则', value: accessRules.length, tone: 'amber' },
{ label: '访问规则', value: adminAccessRules.length, tone: 'amber' },
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
];
}, [accessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]);
}, [adminAccessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]);
const data = useMemo<ConsoleData>(() => ({
accessRules,
accessRules: activePage === 'workspace' ? apiKeyAccessRules : adminAccessRules,
adminTasks,
auditLogs,
apiKeys,
@@ -461,7 +466,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, adminTasks, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]);
}), [activePage, adminAccessRules, adminTasks, apiKeyAccessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions, workerClusterRuntime]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -653,10 +658,11 @@ export function App() {
loadedTransactionQueryKeyRef.current = requestKey;
return;
}
case 'accessRules':
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
? listApiKeyAccessRules(nextToken)
: listAccessRules(nextToken))).items);
case 'adminAccessRules':
setAdminAccessRules((await listAccessRules(nextToken)).items);
return;
case 'apiKeyAccessRules':
setApiKeyAccessRules((await listApiKeyAccessRules(nextToken)).items);
return;
case 'auditLogs':
setAuditLogs((await listAuditLogs(nextToken)).items);
@@ -957,6 +963,28 @@ export function App() {
}
}
async function batchOperateUsers(ids: string[], action: IdentityBatchAction) {
setCoreState('loading');
setCoreMessage('');
try {
const response = await batchGatewayUsers(token, { ids, action });
const affected = new Set(response.ids);
if (action === 'delete') {
setUsers((current) => current.filter((user) => !affected.has(user.id)));
} else {
const status = action === 'enable' ? 'active' : 'disabled';
setUsers((current) => current.map((user) => affected.has(user.id) ? { ...user, status } : user));
}
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户。`);
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '批量操作用户失败');
throw err;
}
}
async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) {
setCoreState('loading');
setCoreMessage('');
@@ -981,6 +1009,7 @@ export function App() {
setUserGroups((current) => current.filter((group) => group.id !== groupId));
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && rule.subjectId === groupId)));
invalidateDataKeys('modelCatalog', 'playgroundModels');
setCoreState('ready');
setCoreMessage('用户组已删除。');
@@ -991,13 +1020,42 @@ export function App() {
}
}
async function batchOperateUserGroups(ids: string[], action: IdentityBatchAction) {
setCoreState('loading');
setCoreMessage('');
try {
const response = await batchUserGroups(token, { ids, action });
const affected = new Set(response.ids);
if (action === 'delete') {
setUserGroups((current) => current.filter((group) => !affected.has(group.id)));
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId && affected.has(tenant.defaultUserGroupId)
? { ...tenant, defaultUserGroupId: undefined }
: tenant));
setUsers((current) => current.map((user) => user.defaultUserGroupId && affected.has(user.defaultUserGroupId)
? { ...user, defaultUserGroupId: undefined }
: user));
setAdminAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'user_group' && affected.has(rule.subjectId))));
} else {
const status = action === 'enable' ? 'active' : 'disabled';
setUserGroups((current) => current.map((group) => affected.has(group.id) ? { ...group, status } : group));
}
invalidateDataKeys('modelCatalog', 'playgroundModels');
setCoreState('ready');
setCoreMessage(`已批量${identityBatchActionLabel(action)} ${response.affectedCount} 个用户组。`);
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '批量操作用户组失败');
throw err;
}
}
async function removeAPIKey(apiKeyId: string) {
setCoreState('loading');
setCoreMessage('');
try {
await deleteApiKey(token, apiKeyId);
setApiKeys((current) => current.filter((item) => item.id !== apiKeyId));
setAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId)));
setApiKeyAccessRules((current) => current.filter((rule) => !(rule.subjectType === 'api_key' && rule.subjectId === apiKeyId)));
setApiKeySecretsById((current) => {
const next = { ...current };
delete next[apiKeyId];
@@ -1034,7 +1092,7 @@ export function App() {
setCoreMessage('');
try {
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
setAdminAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
@@ -1050,7 +1108,7 @@ export function App() {
setCoreMessage('');
try {
await deleteAccessRule(token, ruleId);
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
setAdminAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage('访问权限规则已删除。');
@@ -1066,7 +1124,7 @@ export function App() {
setCoreMessage('');
try {
const response = await batchAccessRules(token, input);
setAccessRules(response.items);
setAdminAccessRules(response.items);
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage('访问权限已更新。');
@@ -1192,7 +1250,7 @@ export function App() {
setCoreMessage('');
try {
const response = await batchApiKeyAccessRules(token, input);
setAccessRules(response.items);
setApiKeyAccessRules(response.items);
setCoreState('ready');
setCoreMessage('API Key 权限已更新。');
} catch (err) {
@@ -1258,7 +1316,8 @@ export function App() {
setPricingRuleSets([]);
setRunnerPolicy(null);
setRuntimePolicySets([]);
setAccessRules([]);
setAdminAccessRules([]);
setApiKeyAccessRules([]);
setAuditLogs([]);
setRateLimitWindows([]);
setModelRateLimits([]);
@@ -1466,6 +1525,7 @@ export function App() {
transactionQuery={workspaceTransactionQuery}
transactionTotal={walletTransactionTotal}
onBatchAccessRules={batchSaveAPIKeyAccessRules}
onRefreshAccessRules={() => ensureData(['apiKeyAccessRules'], token, true)}
onDeleteApiKey={removeAPIKey}
onApiKeyFormChange={setApiKeyForm}
onSectionChange={navigateWorkspaceSection}
@@ -1517,6 +1577,8 @@ export function App() {
onDeleteTenant={removeTenant}
onDeleteUser={removeUser}
onDeleteUserGroup={removeUserGroup}
onBatchUsers={batchOperateUsers}
onBatchUserGroups={batchOperateUserGroups}
onSaveBaseModel={saveBaseModel}
onResetAllBaseModels={resetAllBaseModelsToDefault}
onResetBaseModel={resetBaseModelToDefault}
@@ -1710,7 +1772,13 @@ function clampTransactionPageSize(value: number) {
return Math.min(100, Math.max(1, normalized));
}
function dataKeysForRoute(
function identityBatchActionLabel(action: IdentityBatchAction) {
if (action === 'enable') return '启用';
if (action === 'disable') return '禁用';
return '删除';
}
export function dataKeysForRoute(
activePage: PageKey,
adminSection: AdminSection,
workspaceSection: WorkspaceSection,
@@ -1729,7 +1797,7 @@ function dataKeysForRoute(
if (activePage === 'workspace') {
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
if (workspaceSection === 'billing') return ['wallet'];
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules'];
if (workspaceSection === 'apiKeys') return ['apiKeys', 'apiKeyAccessRules'];
if (workspaceSection === 'tasks') return ['tasks'];
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
return [];
@@ -1738,7 +1806,7 @@ function dataKeysForRoute(
if (activePage !== 'admin') return [];
switch (adminSection) {
case 'overview':
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'accessRules'];
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'adminAccessRules'];
case 'globalModels':
return ['providers'];
case 'pricing':
@@ -1760,7 +1828,7 @@ function dataKeysForRoute(
case 'users':
return ['users', 'tenants', 'userGroups'];
case 'userGroups':
return ['userGroups', 'accessRules', 'platforms', 'models'];
return ['userGroups', 'adminAccessRules', 'platforms', 'models'];
case 'auditLogs':
return ['auditLogs'];
case 'systemSettings':
+29
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
batchGatewayUsers,
batchUserGroups,
cancelIdentityPairing,
connectSecurityEventTransmitter,
createResponse,
@@ -253,6 +255,33 @@ describe('API Key permission resources', () => {
});
});
describe('identity batch transports', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('posts explicit actions and selected ids to the user and user-group batch endpoints', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({
action: 'disable', requestedCount: 2, affectedCount: 2, ids: ['id-a', 'id-b'],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
vi.stubGlobal('fetch', fetchMock);
await batchGatewayUsers('manager-token', { action: 'disable', ids: ['id-a', 'id-b'] });
await batchUserGroups('manager-token', { action: 'delete', ids: ['group-a'] });
const [userURL, userInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(userURL).toContain('/api/admin/users/batch');
expect(userInit.method).toBe('POST');
expect(JSON.parse(String(userInit.body))).toEqual({ action: 'disable', ids: ['id-a', 'id-b'] });
const [groupURL, groupInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(groupURL).toContain('/api/admin/user-groups/batch');
expect(JSON.parse(String(groupInit.body))).toEqual({ action: 'delete', ids: ['group-a'] });
});
});
describe('Public Agent resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
+24
View File
@@ -39,6 +39,8 @@ import type {
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletTransaction,
IdentityBatchRequest,
IdentityBatchResponse,
IntegrationPlatform,
ListResponse,
ModelCatalogResponse,
@@ -419,6 +421,17 @@ export async function deleteGatewayUser(token: string, userId: string): Promise<
});
}
export async function batchGatewayUsers(
token: string,
input: IdentityBatchRequest,
): Promise<IdentityBatchResponse> {
return request<IdentityBatchResponse>('/api/admin/users/batch', {
body: input,
method: 'POST',
token,
});
}
export async function listAuditLogs(token: string): Promise<ListResponse<GatewayAuditLog>> {
return request<ListResponse<GatewayAuditLog>>('/api/admin/audit-logs', { token });
}
@@ -454,6 +467,17 @@ export async function deleteUserGroup(token: string, groupId: string): Promise<v
});
}
export async function batchUserGroups(
token: string,
input: IdentityBatchRequest,
): Promise<IdentityBatchResponse> {
return request<IdentityBatchResponse>('/api/admin/user-groups/batch', {
body: input,
method: 'POST',
token,
});
}
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
return request<ListResponse<GatewayAccessRule>>('/api/admin/access-rules', { token });
}
+7
View File
@@ -11,6 +11,7 @@ import type {
GatewayTenantUpsertRequest,
GatewayRunnerPolicyUpsertRequest,
GatewayUserUpsertRequest,
IdentityBatchAction,
IntegrationPlatform,
PlatformDynamicPriorityUpdateRequest,
PricingRuleSetUpsertRequest,
@@ -73,6 +74,8 @@ export function AdminPage(props: {
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
onResetAllBaseModels: () => Promise<void>;
onResetBaseModel: (baseModelId: string) => Promise<void>;
@@ -227,6 +230,8 @@ function identityPanelProps(props: {
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@@ -240,6 +245,8 @@ function identityPanelProps(props: {
onDeleteTenant: props.onDeleteTenant,
onDeleteUser: props.onDeleteUser,
onDeleteUserGroup: props.onDeleteUserGroup,
onBatchUsers: props.onBatchUsers,
onBatchUserGroups: props.onBatchUserGroups,
onSaveTenant: props.onSaveTenant,
onSaveUser: props.onSaveUser,
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
+33 -25
View File
@@ -49,6 +49,7 @@ export function WorkspacePage(props: {
transactionQuery: WorkspaceTransactionQuery;
transactionTotal: number;
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
onRefreshAccessRules: () => Promise<void>;
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSectionChange: (value: WorkspaceSection) => void;
@@ -396,6 +397,7 @@ function ApiKeyPanel(props: {
state: LoadState;
token: string;
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
onRefreshAccessRules: () => Promise<void>;
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
@@ -413,6 +415,7 @@ function ApiKeyPanel(props: {
const [policyDiagnostics, setPolicyDiagnostics] = useState<GatewayAPIKeyAccessRuleDiagnostic[]>([]);
const [policyState, setPolicyState] = useState<LoadState>('idle');
const [policyError, setPolicyError] = useState('');
const policyRequestIdRef = useRef(0);
const selectedPolicyKey = useMemo(
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
[policyApiKeyId, props.data.apiKeys],
@@ -423,41 +426,46 @@ function ApiKeyPanel(props: {
);
const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]);
useEffect(() => {
if (!policyApiKeyId) {
setPolicyModels([]);
setPolicyDiagnostics([]);
setPolicyState('idle');
setPolicyError('');
return;
}
let cancelled = false;
async function loadPolicy(apiKeyId: string) {
const requestId = ++policyRequestIdRef.current;
setPolicyState('loading');
setPolicyError('');
void listApiKeyAssignableModels(props.token, policyApiKeyId).then((response) => {
if (cancelled) return;
try {
const [response] = await Promise.all([
listApiKeyAssignableModels(props.token, apiKeyId),
props.onRefreshAccessRules(),
]);
if (requestId !== policyRequestIdRef.current) return;
setPolicyModels(response.items);
setPolicyDiagnostics(response.ruleDiagnostics);
setPolicyState('ready');
}).catch((error) => {
if (cancelled) return;
} catch (error) {
if (requestId !== policyRequestIdRef.current) return;
setPolicyModels([]);
setPolicyDiagnostics([]);
setPolicyState('error');
setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败');
});
return () => {
cancelled = true;
};
}, [policyApiKeyId, props.token]);
}
}
function openPolicyDialog(item: GatewayApiKey) {
setPolicyApiKeyId(item.id);
void loadPolicy(item.id);
}
function closePolicyDialog() {
policyRequestIdRef.current += 1;
setPolicyApiKeyId('');
setPolicyModels([]);
setPolicyDiagnostics([]);
setPolicyState('idle');
setPolicyError('');
}
async function savePolicyRules(input: GatewayAccessRuleBatchRequest) {
await props.onBatchAccessRules(input);
if (!policyApiKeyId) return;
const response = await listApiKeyAssignableModels(props.token, policyApiKeyId);
setPolicyModels(response.items);
setPolicyDiagnostics(response.ruleDiagnostics);
setPolicyState('ready');
await loadPolicy(policyApiKeyId);
}
async function copyApiKey(item: GatewayApiKey) {
@@ -572,7 +580,7 @@ function ApiKeyPanel(props: {
</span>
</TableCell>
<TableCell>
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
<button type="button" className="apiKeyPolicyButton" onClick={() => openPolicyDialog(item)}>
<ShieldCheck size={14} />
<span>{permissionSummaryText(summary)}</span>
</button>
@@ -657,10 +665,10 @@ function ApiKeyPanel(props: {
ariaLabel="维护 API Key 权限策略"
bodyClassName="apiKeyPolicyDialogBody"
className="apiKeyPolicyDialog"
footer={<Button type="button" size="sm" onClick={() => setPolicyApiKeyId('')}></Button>}
footer={<Button type="button" size="sm" onClick={closePolicyDialog}></Button>}
open={Boolean(selectedPolicyKey)}
title={selectedPolicyKey ? `权限策略:${selectedPolicyKey.name}` : '权限策略'}
onClose={() => setPolicyApiKeyId('')}
onClose={closePolicyDialog}
onSubmit={(event) => event.preventDefault()}
>
{policyError && <p className="formMessage error">{policyError}</p>}
@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { IdentityBatchToolbar, identityBatchActionCopy } from './IdentityBatchToolbar';
import { pruneSelectedIds, updateSelectedIds } from './IdentityManagementPanels';
describe('identity batch operations', () => {
it('renders enable, disable and delete actions with the current selection count', () => {
const html = renderToStaticMarkup(
<IdentityBatchToolbar entityLabel="用户" loading={false} selectedCount={2} onAction={vi.fn()} />,
);
expect(html).toContain('已选');
expect(html).toContain('批量启用');
expect(html).toContain('批量禁用');
expect(html).toContain('批量删除');
expect(identityBatchActionCopy('delete', '用户组', 3).description).toContain('3 个用户组');
});
it('adds, removes and prunes selections after list updates', () => {
const selected = updateSelectedIds(new Set(['a']), 'b', true);
expect(Array.from(selected).sort()).toEqual(['a', 'b']);
expect(Array.from(updateSelectedIds(selected, 'a', false))).toEqual(['b']);
expect(Array.from(pruneSelectedIds(selected, ['b', 'c']))).toEqual(['b']);
});
});
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Ban, CheckCircle2, Trash2 } from 'lucide-react';
import type { IdentityBatchAction } from '@easyai-ai-gateway/contracts';
import { Button, ConfirmDialog } from '../../components/ui';
export function IdentityBatchToolbar(props: {
entityLabel: string;
loading: boolean;
selectedCount: number;
onAction: (action: IdentityBatchAction) => Promise<void>;
}) {
const [pendingAction, setPendingAction] = useState<IdentityBatchAction | null>(null);
const disabled = props.loading || props.selectedCount === 0;
const copy = pendingAction ? identityBatchActionCopy(pendingAction, props.entityLabel, props.selectedCount) : null;
async function confirm() {
if (!pendingAction) return;
await props.onAction(pendingAction);
setPendingAction(null);
}
return (
<>
<div className="identityBatchToolbar" aria-label={`${props.entityLabel}批量操作`}>
<span> <strong>{props.selectedCount}</strong> </span>
<div>
<Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => setPendingAction('enable')}>
<CheckCircle2 size={14} />
</Button>
<Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => setPendingAction('disable')}>
<Ban size={14} />
</Button>
<Button type="button" size="sm" variant="destructive" disabled={disabled} onClick={() => setPendingAction('delete')}>
<Trash2 size={14} />
</Button>
</div>
</div>
<ConfirmDialog
confirmLabel={copy?.confirmLabel}
confirmVariant={pendingAction === 'delete' ? 'destructive' : 'default'}
description={copy?.description}
loading={props.loading}
open={Boolean(pendingAction)}
title={copy?.title ?? ''}
onCancel={() => setPendingAction(null)}
onConfirm={confirm}
/>
</>
);
}
export function identityBatchActionCopy(action: IdentityBatchAction, entityLabel: string, count: number) {
if (action === 'enable') {
return {
confirmLabel: '确认启用',
description: `选中的 ${count}${entityLabel}将恢复为 active 状态。`,
title: `批量启用${entityLabel}`,
};
}
if (action === 'disable') {
return {
confirmLabel: '确认禁用',
description: `选中的 ${count}${entityLabel}将被设为 disabled。`,
title: `批量禁用${entityLabel}`,
};
}
return {
confirmLabel: '确认批量删除',
description: `将删除选中的 ${count}${entityLabel},该操作不能从当前页面撤销。`,
title: `批量删除${entityLabel}`,
};
}
@@ -1,4 +1,4 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react';
import type {
GatewayTenant,
@@ -7,6 +7,7 @@ import type {
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletAccount,
IdentityBatchAction,
UserGroup,
UserGroupUpsertRequest,
WalletRechargeRequest,
@@ -18,6 +19,7 @@ import {
CardContent,
CardHeader,
CardTitle,
Checkbox,
ConfirmDialog,
FormDialog,
Input,
@@ -33,6 +35,7 @@ import {
import type { ConsoleData } from '../../app-state';
import type { LoadState } from '../../types';
import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor';
import { IdentityBatchToolbar } from './IdentityBatchToolbar';
import {
quotaPolicyFromForm,
quotaPolicySummary,
@@ -244,8 +247,15 @@ export function UsersPanel(props: IdentityPanelProps) {
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
const [walletUser, setWalletUser] = useState<GatewayUser | null>(null);
const [walletForm, setWalletForm] = useState<WalletForm>(() => defaultWalletForm());
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(() => new Set());
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
const allUsersSelected = props.data.users.length > 0 && props.data.users.every((user) => selectedUserIds.has(user.id));
const someUsersSelected = props.data.users.some((user) => selectedUserIds.has(user.id));
useEffect(() => {
setSelectedUserIds((current) => pruneSelectedIds(current, props.data.users.map((user) => user.id)));
}, [props.data.users]);
function openCreateDialog() {
setEditingId('');
@@ -303,6 +313,16 @@ export function UsersPanel(props: IdentityPanelProps) {
}
}
async function batchUsers(action: IdentityBatchAction) {
setLocalError('');
try {
await props.onBatchUsers(Array.from(selectedUserIds), action);
setSelectedUserIds(new Set());
} catch (err) {
setLocalError(err instanceof Error ? err.message : '批量操作用户失败');
}
}
async function submitWallet(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
@@ -346,9 +366,25 @@ export function UsersPanel(props: IdentityPanelProps) {
actionLabel="新增用户"
onCreate={openCreateDialog}
/>
{props.data.users.length > 0 && (
<IdentityBatchToolbar
entityLabel="用户"
loading={props.state === 'loading'}
selectedCount={selectedUserIds.size}
onAction={batchUsers}
/>
)}
{props.data.users.length ? (
<Table className="identityDataTable userTable">
<TableRow>
<TableHead className="identitySelectionCell">
<Checkbox
aria-label="选择全部用户"
checked={allUsersSelected ? true : someUsersSelected ? 'indeterminate' : false}
disabled={props.state === 'loading'}
onCheckedChange={(checked) => setSelectedUserIds(checked === true ? new Set(props.data.users.map((user) => user.id)) : new Set())}
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -360,6 +396,14 @@ export function UsersPanel(props: IdentityPanelProps) {
</TableRow>
{props.data.users.map((user) => (
<TableRow key={user.id}>
<TableCell className="identitySelectionCell">
<Checkbox
aria-label={`选择用户 ${user.username}`}
checked={selectedUserIds.has(user.id)}
disabled={props.state === 'loading'}
onCheckedChange={(checked) => setSelectedUserIds((current) => updateSelectedIds(current, user.id, checked === true))}
/>
</TableCell>
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
<TableCell>{roleLabel(user.roles)}</TableCell>
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
@@ -443,6 +487,13 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
const [localError, setLocalError] = useState('');
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
const [permissionGroup, setPermissionGroup] = useState<UserGroup | null>(null);
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(() => new Set());
const allGroupsSelected = props.data.userGroups.length > 0 && props.data.userGroups.every((group) => selectedGroupIds.has(group.id));
const someGroupsSelected = props.data.userGroups.some((group) => selectedGroupIds.has(group.id));
useEffect(() => {
setSelectedGroupIds((current) => pruneSelectedIds(current, props.data.userGroups.map((group) => group.id)));
}, [props.data.userGroups]);
function openCreateDialog() {
setEditingId('');
@@ -484,6 +535,16 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
}
}
async function batchGroups(action: IdentityBatchAction) {
setLocalError('');
try {
await props.onBatchUserGroups(Array.from(selectedGroupIds), action);
setSelectedGroupIds(new Set());
} catch (err) {
setLocalError(err instanceof Error ? err.message : '批量操作用户组失败');
}
}
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
@@ -496,9 +557,25 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
actionLabel="新增用户组"
onCreate={openCreateDialog}
/>
{props.data.userGroups.length > 0 && (
<IdentityBatchToolbar
entityLabel="用户组"
loading={props.state === 'loading'}
selectedCount={selectedGroupIds.size}
onAction={batchGroups}
/>
)}
{props.data.userGroups.length ? (
<Table className="identityDataTable groupTable">
<TableRow>
<TableHead className="identitySelectionCell">
<Checkbox
aria-label="选择全部用户组"
checked={allGroupsSelected ? true : someGroupsSelected ? 'indeterminate' : false}
disabled={props.state === 'loading'}
onCheckedChange={(checked) => setSelectedGroupIds(checked === true ? new Set(props.data.userGroups.map((group) => group.id)) : new Set())}
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -512,6 +589,14 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id);
return (
<TableRow key={group.id}>
<TableCell className="identitySelectionCell">
<Checkbox
aria-label={`选择用户组 ${group.name}`}
checked={selectedGroupIds.has(group.id)}
disabled={props.state === 'loading'}
onCheckedChange={(checked) => setSelectedGroupIds((current) => updateSelectedIds(current, group.id, checked === true))}
/>
</TableCell>
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
<TableCell>{group.source}</TableCell>
<TableCell>{group.priority}</TableCell>
@@ -575,7 +660,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
<>
<div className="accessGroupHint userGroupPermissionHint">
<ShieldCheck size={15} />
<span></span>
<span></span>
</div>
<AccessPermissionEditor
key={permissionGroup.id}
@@ -611,6 +696,8 @@ type IdentityPanelProps = {
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
onBatchUsers: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onBatchUserGroups: (ids: string[], action: IdentityBatchAction) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
@@ -618,6 +705,18 @@ type IdentityPanelProps = {
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
};
export function updateSelectedIds(current: Set<string>, id: string, selected: boolean) {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
}
export function pruneSelectedIds(current: Set<string>, availableIds: string[]) {
const available = new Set(availableIds);
return new Set(Array.from(current).filter((id) => available.has(id)));
}
const defaultRechargeReason = '手动充值';
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
@@ -1018,8 +1117,8 @@ function PolicySummary(props: { parts: string[] }) {
function permissionRuleSummary(summary: ReturnType<typeof countAccessPermissionRules>) {
const allow = summary.allow.platforms + summary.allow.models;
const deny = summary.deny.platforms + summary.deny.models;
if (!allow && !deny) return '模型权限:未配置,默认放行';
return `模型权限:专属 ${allow} 条,拒绝 ${deny}`;
if (!allow && !deny) return '模型权限:未配置白名单,继承上级';
return `模型权限:允许 ${allow} 条,拒绝 ${deny}`;
}
function stringifyJson(value: unknown) {
+34 -4
View File
@@ -435,6 +435,30 @@
align-items: center;
}
.identityBatchToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--muted-foreground);
font-size: var(--font-size-sm);
}
.identityBatchToolbar > div {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.identitySelectionCell {
flex: 0 0 2.5rem;
justify-content: center;
}
.accessGroupToolbar,
.accessGroupHint {
display: flex;
@@ -494,6 +518,10 @@
color: var(--foreground);
}
.accessRuleCleanupButton {
margin-left: auto;
}
.accessRuleDiagnosticRow {
justify-content: space-between;
padding-top: 0.625rem;
@@ -774,15 +802,16 @@
}
.userTable .shTableRow {
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
min-width: 1120px;
grid-template-columns: 40px minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
min-width: 1160px;
}
.groupTable .shTableRow {
grid-template-columns: minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr);
min-width: 1305px;
grid-template-columns: 40px minmax(190px, 1.15fr) minmax(90px, 0.5fr) minmax(82px, 0.42fr) minmax(145px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(82px, 0.42fr) minmax(154px, 0.78fr);
min-width: 1345px;
}
.userTable .shTableRow > :last-child,
.groupTable .shTableRow > :last-child {
position: sticky;
right: 0;
@@ -791,6 +820,7 @@
box-shadow: -8px 0 12px -12px rgba(16, 24, 40, 0.45);
}
.userTable > .shTableRow:first-child > :last-child,
.groupTable > .shTableRow:first-child > :last-child {
z-index: 2;
background: var(--surface-subtle);