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(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }>) =>
|
||||||
|
|||||||
@@ -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={(
|
||||||
|
<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 }}>
|
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
|
||||||
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user