forked from boos/BoosAPI
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:
@@ -154,4 +154,29 @@ describe('PUT /api/channels/batch', () => {
|
||||
expect(dbA?.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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
saveRouteDecisionSnapshots,
|
||||
} from '../../services/routeDecisionSnapshotStore.js';
|
||||
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
@@ -1110,6 +1111,48 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
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
|
||||
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);
|
||||
@@ -1262,6 +1305,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Rebuild routes/channels from model availability.
|
||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||
@@ -1303,3 +1347,4 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -504,6 +504,8 @@ export const api = {
|
||||
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) }),
|
||||
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) }),
|
||||
updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) =>
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function TokenRoutes() {
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = 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 [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
||||
@@ -751,6 +754,62 @@ export default function TokenRoutes() {
|
||||
});
|
||||
}, [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(() => {
|
||||
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||
}, [filteredRoutes.length]);
|
||||
@@ -1298,6 +1357,14 @@ export default function TokenRoutes() {
|
||||
{tr('新建群组')}
|
||||
</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
|
||||
type="button"
|
||||
aria-pressed={showZeroChannelRoutes}
|
||||
@@ -1407,6 +1474,43 @@ export default function TokenRoutes() {
|
||||
/>
|
||||
|
||||
{/* 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'}>
|
||||
{visibleRoutes.map((route) => {
|
||||
const isExpanded = expandedRouteIds.includes(route.id);
|
||||
@@ -1414,16 +1518,37 @@ export default function TokenRoutes() {
|
||||
const exactRoute = isRouteExactModel(route);
|
||||
const explicitGroupRoute = isExplicitGroupRoute(route);
|
||||
const channelManagementDisabled = explicitGroupRoute;
|
||||
const routeTitle = resolveRouteTitle(route);
|
||||
|
||||
const isSelectable = selectableRouteIds.has(route.id);
|
||||
const isSelected = selectedRouteIds.has(route.id);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div key={route.id} style={{ display: 'grid', gap: 8 }}>
|
||||
<MobileCard
|
||||
title={resolveRouteTitle(route)}
|
||||
title={routeTitle}
|
||||
headerActions={(
|
||||
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
|
||||
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{batchSelectMode && isSelectable && (
|
||||
<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={(
|
||||
<>
|
||||
@@ -1513,7 +1638,7 @@ export default function TokenRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const routeCard = (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
route={route}
|
||||
@@ -1547,6 +1672,40 @@ export default function TokenRoutes() {
|
||||
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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user