feat: 新增路由批量禁用/启用功能 (#295)

* feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files.

* fix: fix Feishu and Larksuite webhook support to the notification service with updated UI and tests.

* feat: 新增路由批量禁用/启用功能

- 后端新增 POST /api/routes/batch 接口,支持批量 enable/disable
- 前端新增批量操作模式,支持 checkbox 选择路由
- 浮动操作栏显示选中数量,支持全选/取消全选
- 批量操作完成后自动退出选择模式并刷新列表

* fix: complete route batch actions on mobile

---------

Co-authored-by: Xiang Li <xiangli>
Co-authored-by: cita-777 <juricek.chen@gmail.com>
This commit is contained in:
Sean Lee
2026-03-28 16:54:09 +08:00
committed by GitHub
parent 5ae452ab45
commit 3bf3cd1622
5 changed files with 966 additions and 677 deletions
+58
View File
@@ -11,6 +11,7 @@ const { apiMock, getBrandMock } = vi.hoisted(() => ({
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
batchUpdateRoutes: vi.fn(),
updateRoute: vi.fn(),
addRoute: vi.fn(),
},
@@ -67,6 +68,7 @@ async function flushMicrotasks() {
describe('TokenRoutes mobile actions', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
const originalWindow = (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window;
beforeEach(() => {
vi.clearAllMocks();
@@ -115,13 +117,23 @@ describe('TokenRoutes mobile actions', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.batchUpdateRoutes.mockResolvedValue({ success: true, updatedCount: 1 });
apiMock.updateRoute.mockResolvedValue({});
apiMock.addRoute.mockResolvedValue({});
(globalThis as { window?: { confirm?: (message?: string) => boolean } }).window = {
...(originalWindow || {}),
confirm: vi.fn(() => true),
};
});
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
if (originalWindow) {
(globalThis as { window?: { confirm?: (message?: string) => boolean } }).window = originalWindow;
} else {
delete (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window;
}
});
it('shows mobile detail expansion and direct management actions', async () => {
@@ -166,4 +178,50 @@ describe('TokenRoutes mobile actions', () => {
root?.unmount();
}
});
it('lets mobile users select a route and batch disable it', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const batchModeButton = findButtonByText(root!.root, '批量操作');
await act(async () => {
batchModeButton.props.onClick();
});
await flushMicrotasks();
const routeCheckbox = root!.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['data-testid'] === 'route-select-1'
));
await act(async () => {
routeCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchDisableButton = findButtonByText(root!.root, '批量禁用');
await act(async () => {
batchDisableButton.props.onClick();
});
await flushMicrotasks();
expect(globalThis.window.confirm).toHaveBeenCalledWith('确认批量禁用 1 条路由?');
expect(apiMock.batchUpdateRoutes).toHaveBeenCalledWith({ ids: [1], action: 'disable' });
expect(collectText(root!.root)).toContain('批量操作');
} finally {
root?.unmount();
}
});
});