[codex] Add route cooldown controls (#365)
* Add route cooldown controls * Address route cooldown review comments
This commit is contained in:
@@ -386,6 +386,7 @@ export type RuntimeSettingsPayload = {
|
||||
notifyCooldownSec?: number;
|
||||
adminIpAllowlist?: string[] | string;
|
||||
routingFallbackUnitCost?: number;
|
||||
tokenRouterFailureCooldownMaxSec?: number;
|
||||
routingWeights?: RuntimeRoutingWeightsPayload;
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
@@ -717,6 +718,7 @@ 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' }),
|
||||
clearRouteCooldown: (id: number) => request(`/api/routes/${id}/cooldown/clear`, { method: 'POST' }),
|
||||
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) }),
|
||||
|
||||
@@ -28,6 +28,13 @@ import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
||||
const FACTORY_RESET_CONFIRM_SECONDS = 3;
|
||||
const SECONDS_PER_DAY = 24 * 60 * 60;
|
||||
const ROUTE_COOLDOWN_UNIT_OPTIONS = [
|
||||
{ value: 'second', label: '秒', multiplierSec: 1 },
|
||||
{ value: 'minute', label: '分钟', multiplierSec: 60 },
|
||||
{ value: 'hour', label: '小时', multiplierSec: 60 * 60 },
|
||||
{ value: 'day', label: '天', multiplierSec: SECONDS_PER_DAY },
|
||||
] as const;
|
||||
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
|
||||
{ value: 'cron', label: 'Cron' },
|
||||
{ value: 'interval', label: '间隔签到' },
|
||||
@@ -40,6 +47,7 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
|
||||
};
|
||||
});
|
||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
||||
|
||||
type RuntimeSettings = {
|
||||
checkinCron: string;
|
||||
@@ -55,6 +63,8 @@ type RuntimeSettings = {
|
||||
proxySessionChannelConcurrencyLimit: number;
|
||||
proxySessionChannelQueueWaitMs: number;
|
||||
routingFallbackUnitCost: number;
|
||||
routeFailureCooldownMaxValue: number;
|
||||
routeFailureCooldownMaxUnit: RouteCooldownUnit;
|
||||
routingWeights: RoutingWeights;
|
||||
systemProxyUrl: string;
|
||||
proxyErrorKeywords: string[];
|
||||
@@ -184,6 +194,35 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRouteCooldownInput(seconds: number | null | undefined): {
|
||||
value: number;
|
||||
unit: RouteCooldownUnit;
|
||||
} {
|
||||
const normalizedSeconds = Number.isFinite(Number(seconds)) && Number(seconds) > 0
|
||||
? Math.max(1, Math.trunc(Number(seconds)))
|
||||
: 30 * SECONDS_PER_DAY;
|
||||
|
||||
for (const option of [...ROUTE_COOLDOWN_UNIT_OPTIONS].reverse()) {
|
||||
if (normalizedSeconds % option.multiplierSec === 0) {
|
||||
return {
|
||||
value: normalizedSeconds / option.multiplierSec,
|
||||
unit: option.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: normalizedSeconds,
|
||||
unit: 'second',
|
||||
};
|
||||
}
|
||||
|
||||
function toRouteCooldownSeconds(value: number, unit: RouteCooldownUnit): number {
|
||||
const normalizedValue = Number.isFinite(value) && value > 0 ? Math.max(1, Math.trunc(value)) : 1;
|
||||
const unitConfig = ROUTE_COOLDOWN_UNIT_OPTIONS.find((option) => option.value === unit) || ROUTE_COOLDOWN_UNIT_OPTIONS[0];
|
||||
return normalizedValue * unitConfig.multiplierSec;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -201,6 +240,8 @@ export default function Settings() {
|
||||
proxySessionChannelConcurrencyLimit: 2,
|
||||
proxySessionChannelQueueWaitMs: 1500,
|
||||
routingFallbackUnitCost: 1,
|
||||
routeFailureCooldownMaxValue: 30,
|
||||
routeFailureCooldownMaxUnit: 'day',
|
||||
routingWeights: defaultWeights,
|
||||
systemProxyUrl: '',
|
||||
proxyErrorKeywords: [],
|
||||
@@ -414,6 +455,7 @@ export default function Settings() {
|
||||
api.getRuntimeDatabaseConfig(),
|
||||
]);
|
||||
setMaskedToken(authInfo.masked || '****');
|
||||
const routeCooldownInput = resolveRouteCooldownInput(runtimeInfo.tokenRouterFailureCooldownMaxSec);
|
||||
setRuntime({
|
||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
|
||||
@@ -438,6 +480,8 @@ export default function Settings() {
|
||||
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
||||
? Number(runtimeInfo.routingFallbackUnitCost)
|
||||
: 1,
|
||||
routeFailureCooldownMaxValue: routeCooldownInput.value,
|
||||
routeFailureCooldownMaxUnit: routeCooldownInput.unit,
|
||||
routingWeights: {
|
||||
...defaultWeights,
|
||||
...(runtimeInfo.routingWeights || {}),
|
||||
@@ -845,6 +889,10 @@ export default function Settings() {
|
||||
await api.updateRuntimeSettings({
|
||||
routingWeights: runtime.routingWeights,
|
||||
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
|
||||
tokenRouterFailureCooldownMaxSec: toRouteCooldownSeconds(
|
||||
runtime.routeFailureCooldownMaxValue,
|
||||
runtime.routeFailureCooldownMaxUnit,
|
||||
),
|
||||
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
||||
});
|
||||
toast.success('Routing weights saved');
|
||||
@@ -1498,6 +1546,50 @@ export default function Settings() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12, maxWidth: 420 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
普通失败冷却上限
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="路由失败冷却上限数值"
|
||||
min={1}
|
||||
step={1}
|
||||
value={runtime.routeFailureCooldownMaxValue}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
setRuntime((prev) => ({
|
||||
...prev,
|
||||
routeFailureCooldownMaxValue: Number.isFinite(nextValue) && nextValue > 0
|
||||
? Math.max(1, Math.trunc(nextValue))
|
||||
: prev.routeFailureCooldownMaxValue,
|
||||
}));
|
||||
}}
|
||||
style={{ ...inputStyle, flex: '1 1 180px', marginBottom: 0 }}
|
||||
/>
|
||||
<div style={{ width: 132, minWidth: 132 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={runtime.routeFailureCooldownMaxUnit}
|
||||
onChange={(nextValue) => {
|
||||
setRuntime((prev) => ({
|
||||
...prev,
|
||||
routeFailureCooldownMaxUnit: nextValue as RouteCooldownUnit,
|
||||
}));
|
||||
}}
|
||||
options={ROUTE_COOLDOWN_UNIT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
placeholder="选择单位"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, lineHeight: 1.6 }}>
|
||||
支持秒、分钟、小时、天。只封顶普通失败与轮询分级冷却;429 限额类冷却仍优先遵循上游 reset 提示,避免过早重试。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => applyRoutingPreset('balanced')}
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function TokenRoutes() {
|
||||
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
||||
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
||||
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
|
||||
const [clearingCooldownByRoute, setClearingCooldownByRoute] = useState<Record<number, boolean>>({});
|
||||
|
||||
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
||||
const [loadingDecision, setLoadingDecision] = useState(false);
|
||||
@@ -1188,6 +1189,41 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearRouteCooldown = async (routeId: number) => {
|
||||
if (clearingCooldownByRoute[routeId]) return;
|
||||
setClearingCooldownByRoute((prev) => ({ ...prev, [routeId]: true }));
|
||||
try {
|
||||
await api.clearRouteCooldown(routeId);
|
||||
toast.success('路由冷却已清除');
|
||||
|
||||
try {
|
||||
await loadChannels(routeId, true);
|
||||
const route = routeSummaries.find((item) => item.id === routeId);
|
||||
if (route) {
|
||||
if (isRouteExactModel(route)) {
|
||||
const res = await api.getRouteDecision(route.modelPattern);
|
||||
setDecisionByRoute((prev) => ({
|
||||
...prev,
|
||||
[routeId]: (res?.decision || null) as RouteDecision | null,
|
||||
}));
|
||||
} else {
|
||||
const res = await api.getRouteWideDecisionsBatch([routeId]);
|
||||
setDecisionByRoute((prev) => ({
|
||||
...prev,
|
||||
[routeId]: (res?.decisions?.[String(routeId)] || null) as RouteDecision | null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('已清除,但刷新失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '清除路由冷却失败');
|
||||
} finally {
|
||||
setClearingCooldownByRoute((prev) => ({ ...prev, [routeId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = async (routeId: number) => {
|
||||
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||
if (isCurrentlyExpanded) {
|
||||
@@ -1348,6 +1384,12 @@ export default function TokenRoutes() {
|
||||
(channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId),
|
||||
[],
|
||||
);
|
||||
const handleClearRouteCooldownRef = useRef(handleClearRouteCooldown);
|
||||
handleClearRouteCooldownRef.current = handleClearRouteCooldown;
|
||||
const stableClearRouteCooldown = useCallback(
|
||||
(routeId: number) => handleClearRouteCooldownRef.current(routeId),
|
||||
[],
|
||||
);
|
||||
|
||||
const addChannelModalRoute = addChannelModalRouteId
|
||||
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
||||
@@ -1701,6 +1743,8 @@ export default function TokenRoutes() {
|
||||
onEdit={stableEditRoute}
|
||||
onDelete={stableDeleteRoute}
|
||||
onToggleEnabled={stableToggleEnabled}
|
||||
onClearCooldown={stableClearRouteCooldown}
|
||||
clearingCooldown={!!clearingCooldownByRoute[route.id]}
|
||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||
channels={channelsByRouteId[route.id]}
|
||||
@@ -1740,6 +1784,8 @@ export default function TokenRoutes() {
|
||||
onEdit={stableEditRoute}
|
||||
onDelete={stableDeleteRoute}
|
||||
onToggleEnabled={stableToggleEnabled}
|
||||
onClearCooldown={stableClearRouteCooldown}
|
||||
clearingCooldown={!!clearingCooldownByRoute[route.id]}
|
||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||
channels={channelsByRouteId[route.id]}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import Settings from './Settings.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: () => null,
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Settings route cooldown cap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 6,
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('converts the selected route cooldown unit back into seconds when saving routing settings', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cooldownInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'number'
|
||||
&& node.props['aria-label'] === '路由失败冷却上限数值'
|
||||
));
|
||||
const cooldownUnitSelect = root.root.find((node) => (
|
||||
node.type === ModernSelect
|
||||
&& node.props.placeholder === '选择单位'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
cooldownInput.props.onChange({ target: { value: '10' } });
|
||||
cooldownUnitSelect.props.onChange('second');
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存路由策略'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||
routingWeights: {
|
||||
baseWeightFactor: 0.5,
|
||||
valueScoreFactor: 0.5,
|
||||
costWeight: 0.4,
|
||||
balanceWeight: 0.3,
|
||||
usageWeight: 0.3,
|
||||
},
|
||||
routingFallbackUnitCost: 1,
|
||||
tokenRouterFailureCooldownMaxSec: 10,
|
||||
disableCrossProtocolFallback: false,
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('infers seconds as the editing unit when the saved cap is not an even day/hour/minute multiple', async () => {
|
||||
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 6,
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
tokenRouterFailureCooldownMaxSec: 10,
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cooldownInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'number'
|
||||
&& node.props['aria-label'] === '路由失败冷却上限数值'
|
||||
));
|
||||
const cooldownUnitSelect = root.root.find((node) => (
|
||||
node.type === ModernSelect
|
||||
&& node.props.placeholder === '选择单位'
|
||||
));
|
||||
|
||||
expect(cooldownInput.props.value).toBe(10);
|
||||
expect(cooldownUnitSelect.props.value).toBe('second');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ describe('RouteCard', () => {
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={vi.fn()}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={undefined}
|
||||
@@ -85,4 +87,53 @@ describe('RouteCard', () => {
|
||||
flexShrink: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a clear cooldown action on expanded cards', () => {
|
||||
const onClearCooldown = vi.fn();
|
||||
const root = create(
|
||||
<RouteCard
|
||||
route={buildRoute()}
|
||||
brand={null}
|
||||
expanded
|
||||
onToggleExpand={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={onClearCooldown}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={[]}
|
||||
loadingChannels={false}
|
||||
routeDecision={null}
|
||||
loadingDecision={false}
|
||||
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||
channelTokenDraft={{}}
|
||||
updatingChannel={{}}
|
||||
savingPriority={false}
|
||||
onTokenDraftChange={vi.fn()}
|
||||
onSaveToken={vi.fn()}
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
onSplitPriorityBucket={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
onAddChannel={vi.fn()}
|
||||
onSiteBlockModel={vi.fn()}
|
||||
expandedSourceGroupMap={{}}
|
||||
onToggleSourceGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '清除冷却'
|
||||
));
|
||||
|
||||
button.props.onClick();
|
||||
expect(onClearCooldown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ type RouteCardProps = {
|
||||
onEdit: (route: RouteSummaryRow) => void;
|
||||
onDelete: (routeId: number) => void;
|
||||
onToggleEnabled: (route: RouteSummaryRow) => void;
|
||||
onClearCooldown: (routeId: number) => void;
|
||||
clearingCooldown: boolean;
|
||||
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
|
||||
updatingRoutingStrategy: boolean;
|
||||
// Channel data (loaded on demand)
|
||||
@@ -106,6 +108,8 @@ function RouteCardInner({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
onClearCooldown,
|
||||
clearingCooldown,
|
||||
onRoutingStrategyChange,
|
||||
updatingRoutingStrategy,
|
||||
channels,
|
||||
@@ -167,6 +171,14 @@ function RouteCardInner({
|
||||
|
||||
const priorityBuckets = buildPriorityBuckets(channels || []);
|
||||
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
|
||||
const renderClearCooldownButton = () => {
|
||||
if (readOnlyRoute) return null;
|
||||
return (
|
||||
<button onClick={() => onClearCooldown(route.id)} className="btn btn-link btn-link-info" disabled={clearingCooldown}>
|
||||
{clearingCooldown ? tr('清除中...') : tr('清除冷却')}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Collapsed card
|
||||
if (!expanded) {
|
||||
@@ -325,6 +337,7 @@ function RouteCardInner({
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{renderClearCooldownButton()}
|
||||
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
||||
)}
|
||||
@@ -358,6 +371,7 @@ function RouteCardInner({
|
||||
</div>
|
||||
{!readOnlyRoute && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{renderClearCooldownButton()}
|
||||
{!exactRoute && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user