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
@@ -154,4 +154,29 @@ describe('PUT /api/channels/batch', () => {
expect(dbA?.manualOverride).toBe(true); expect(dbA?.manualOverride).toBe(true);
expect(dbB?.manualOverride).toBe(true); expect(dbB?.manualOverride).toBe(true);
}); });
it('reports the number of routes actually updated in route batch operations', async () => {
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const res = await app.inject({
method: 'POST',
url: '/api/routes/batch',
payload: {
ids: [route.id, route.id + 999],
action: 'disable',
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
success: true,
updatedCount: 1,
});
const updatedRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
expect(updatedRoute?.enabled).toBe(false);
});
}); });
+45
View File
@@ -20,6 +20,7 @@ import {
saveRouteDecisionSnapshots, saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js'; } from '../../services/routeDecisionSnapshotStore.js';
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
function isExactModelPattern(modelPattern: string): boolean { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
@@ -1110,6 +1111,48 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true }; return { success: true };
}); });
// Batch update routes (enable/disable)
app.post<{ Body: { ids: number[]; action: 'enable' | 'disable' } }>('/api/routes/batch', async (request, reply) => {
const body = request.body;
if (!body || typeof body !== 'object') {
return reply.code(400).send({ success: false, message: '请求体必须是对象' });
}
const action = body.action;
if (action !== 'enable' && action !== 'disable') {
return reply.code(400).send({ success: false, message: 'action 必须是 enable 或 disable' });
}
const rawIds = body.ids;
if (!Array.isArray(rawIds) || rawIds.length === 0) {
return reply.code(400).send({ success: false, message: 'ids 必须是非空数组' });
}
const dedupe = new Set<number>();
const ids: number[] = [];
for (const raw of rawIds) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue;
const id = Math.trunc(raw);
if (id <= 0 || dedupe.has(id)) continue;
dedupe.add(id);
ids.push(id);
if (ids.length >= 500) break;
}
if (ids.length === 0) {
return reply.code(400).send({ success: false, message: 'ids 中没有有效的路由 ID' });
}
const enabled = action === 'enable';
const now = new Date().toISOString();
const updateResult = await db.update(schema.tokenRoutes)
.set({ enabled, updatedAt: now })
.where(inArray(schema.tokenRoutes.id, ids))
.run();
await clearRouteDecisionSnapshots(ids);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(ids);
invalidateTokenRouterCache();
return { success: true, updatedCount: Number(updateResult?.changes || 0) };
});
// Add a channel to a route // Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10); const routeId = parseInt(request.params.id, 10);
@@ -1262,6 +1305,7 @@ export async function tokensRoutes(app: FastifyInstance) {
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true }; return { success: true };
}); });
// Rebuild routes/channels from model availability. // Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
const body = (request.body || {}) as { refreshModels?: boolean }; const body = (request.body || {}) as { refreshModels?: boolean };
@@ -1303,3 +1347,4 @@ export async function tokensRoutes(app: FastifyInstance) {
}); });
}); });
} }
+2
View File
@@ -504,6 +504,8 @@ export const api = {
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }), addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
batchUpdateRoutes: (data: { ids: number[]; action: 'enable' | 'disable' }) =>
request('/api/routes/batch', { method: 'POST', body: JSON.stringify(data) }),
addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }), addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }),
updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) => batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) =>
+164 -5
View File
@@ -128,6 +128,9 @@ export default function TokenRoutes() {
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [rebuilding, setRebuilding] = useState(false); const [rebuilding, setRebuilding] = useState(false);
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
const [batchSelectMode, setBatchSelectMode] = useState(false);
const [selectedRouteIds, setSelectedRouteIds] = useState<Set<number>>(new Set());
const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({}); const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({});
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({}); const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
@@ -751,6 +754,62 @@ export default function TokenRoutes() {
}); });
}, [baseFilteredRoutes, enabledFilter]); }, [baseFilteredRoutes, enabledFilter]);
const selectableRouteIds = useMemo(() => {
return new Set(
filteredRoutes
.filter((route) => route.kind !== 'zero_channel' && route.readOnly !== true && route.isVirtual !== true)
.map((route) => route.id),
);
}, [filteredRoutes]);
const toggleBatchSelectMode = () => {
setBatchSelectMode((prev) => {
if (prev) setSelectedRouteIds(new Set());
return !prev;
});
};
const toggleRouteSelection = (routeId: number) => {
setSelectedRouteIds((prev) => {
const next = new Set(prev);
if (next.has(routeId)) next.delete(routeId);
else next.add(routeId);
return next;
});
};
const selectAllRoutes = () => {
setSelectedRouteIds(new Set(selectableRouteIds));
};
const deselectAllRoutes = () => {
setSelectedRouteIds(new Set());
};
const handleBatchUpdateRoutes = async (action: 'enable' | 'disable') => {
const ids = Array.from(selectedRouteIds).filter((id) => selectableRouteIds.has(id));
if (ids.length === 0) {
toast.info('请先选择要操作的路由');
return;
}
const actionLabel = action === 'disable' ? '禁用' : '启用';
const confirmed = window.confirm(`确认批量${actionLabel} ${ids.length} 条路由?`);
if (!confirmed) return;
setBatchUpdatingRoutes(true);
try {
await api.batchUpdateRoutes({ ids, action });
toast.success(`已批量${actionLabel} ${ids.length} 条路由`);
setSelectedRouteIds(new Set());
setBatchSelectMode(false);
await load();
} catch (e: any) {
toast.error(e.message || `批量${actionLabel}路由失败`);
} finally {
setBatchUpdatingRoutes(false);
}
};
useEffect(() => { useEffect(() => {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
}, [filteredRoutes.length]); }, [filteredRoutes.length]);
@@ -1298,6 +1357,14 @@ export default function TokenRoutes() {
{tr('新建群组')} {tr('新建群组')}
</button> </button>
<button
onClick={toggleBatchSelectMode}
className={`btn ${batchSelectMode ? 'btn-primary' : 'btn-ghost'}`}
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{batchSelectMode ? tr('退出批量') : tr('批量操作')}
</button>
<button <button
type="button" type="button"
aria-pressed={showZeroChannelRoutes} aria-pressed={showZeroChannelRoutes}
@@ -1407,6 +1474,43 @@ export default function TokenRoutes() {
/> />
{/* Route card grid */} {/* Route card grid */}
{/* Batch selection floating bar */}
{batchSelectMode && (
<div style={{
position: 'sticky', top: 0, zIndex: 50,
background: 'var(--color-bg-card, #fff)',
border: '1px solid var(--color-border)',
borderRadius: 8, padding: '10px 16px',
marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}>
{tr('已选择')} <b>{selectedRouteIds.size}</b> / {selectableRouteIds.size} {tr('条路由')}
</span>
<button className="btn btn-ghost" style={{ padding: '4px 12px', fontSize: 12 }} onClick={selectAllRoutes}>{tr('全选')}</button>
<button className="btn btn-ghost" style={{ padding: '4px 12px', fontSize: 12 }} onClick={deselectAllRoutes}>{tr('取消全选')}</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button
className="btn btn-warning"
style={{ padding: '6px 16px', fontSize: 13 }}
disabled={selectedRouteIds.size === 0 || batchUpdatingRoutes}
onClick={() => handleBatchUpdateRoutes('disable')}
>
{batchUpdatingRoutes ? <><span className="spinner spinner-sm" /> {tr('处理中...')}</> : tr('批量禁用')}
</button>
<button
className="btn btn-primary"
style={{ padding: '6px 16px', fontSize: 13 }}
disabled={selectedRouteIds.size === 0 || batchUpdatingRoutes}
onClick={() => handleBatchUpdateRoutes('enable')}
>
{batchUpdatingRoutes ? <><span className="spinner spinner-sm" /> {tr('处理中...')}</> : tr('批量启用')}
</button>
</div>
</div>
)}
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}> <div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => { {visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id); const isExpanded = expandedRouteIds.includes(route.id);
@@ -1414,16 +1518,37 @@ export default function TokenRoutes() {
const exactRoute = isRouteExactModel(route); const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route); const explicitGroupRoute = isExplicitGroupRoute(route);
const channelManagementDisabled = explicitGroupRoute; const channelManagementDisabled = explicitGroupRoute;
const routeTitle = resolveRouteTitle(route);
const isSelectable = selectableRouteIds.has(route.id);
const isSelected = selectedRouteIds.has(route.id);
if (isMobile) { if (isMobile) {
return ( return (
<div key={route.id} style={{ display: 'grid', gap: 8 }}> <div key={route.id} style={{ display: 'grid', gap: 8 }}>
<MobileCard <MobileCard
title={resolveRouteTitle(route)} title={routeTitle}
headerActions={( headerActions={(
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} {batchSelectMode && isSelectable && (
</span> <label
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', fontSize: 12 }}
>
<input
data-testid={`route-select-${route.id}`}
aria-label={`选择路由 ${routeTitle}`}
type="checkbox"
checked={isSelected}
onChange={() => toggleRouteSelection(route.id)}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary, #4f46e5)' }}
/>
<span>{tr('选择')}</span>
</label>
)}
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
</span>
</div>
)} )}
footerActions={( footerActions={(
<> <>
@@ -1513,7 +1638,7 @@ export default function TokenRoutes() {
); );
} }
return ( const routeCard = (
<RouteCard <RouteCard
key={route.id} key={route.id}
route={route} route={route}
@@ -1547,6 +1672,40 @@ export default function TokenRoutes() {
onToggleSourceGroup={stableToggleSourceGroup} onToggleSourceGroup={stableToggleSourceGroup}
/> />
); );
if (batchSelectMode && isSelectable) {
return (
<div key={route.id} style={{ display: 'flex', gap: 0, alignItems: 'stretch', ...(isExpanded ? { gridColumn: '1 / -1' } : {}) }}>
<div
onClick={() => toggleRouteSelection(route.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 36, minHeight: '100%', cursor: 'pointer',
borderRadius: '8px 0 0 8px',
background: isSelected ? 'var(--color-primary, #4f46e5)' : 'var(--color-bg-card, #fff)',
border: '1px solid var(--color-border)',
borderRight: 'none',
transition: 'background 0.15s',
}}
>
<input
data-testid={`route-select-${route.id}`}
aria-label={`选择路由 ${routeTitle}`}
type="checkbox"
checked={isSelected}
onChange={() => toggleRouteSelection(route.id)}
onClick={(e) => e.stopPropagation()}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary, #4f46e5)' }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{routeCard}
</div>
</div>
);
}
return routeCard;
})} })}
</div> </div>
+58
View File
@@ -11,6 +11,7 @@ const { apiMock, getBrandMock } = vi.hoisted(() => ({
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
batchUpdateRoutes: vi.fn(),
updateRoute: vi.fn(), updateRoute: vi.fn(),
addRoute: vi.fn(), addRoute: vi.fn(),
}, },
@@ -67,6 +68,7 @@ async function flushMicrotasks() {
describe('TokenRoutes mobile actions', () => { describe('TokenRoutes mobile actions', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver; const originalIntersectionObserver = globalThis.IntersectionObserver;
const originalWindow = (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -115,13 +117,23 @@ describe('TokenRoutes mobile actions', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.batchUpdateRoutes.mockResolvedValue({ success: true, updatedCount: 1 });
apiMock.updateRoute.mockResolvedValue({}); apiMock.updateRoute.mockResolvedValue({});
apiMock.addRoute.mockResolvedValue({}); apiMock.addRoute.mockResolvedValue({});
(globalThis as { window?: { confirm?: (message?: string) => boolean } }).window = {
...(originalWindow || {}),
confirm: vi.fn(() => true),
};
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver; 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 () => { it('shows mobile detail expansion and direct management actions', async () => {
@@ -166,4 +178,50 @@ describe('TokenRoutes mobile actions', () => {
root?.unmount(); 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();
}
});
}); });