1
0
forked from boos/BoosAPI

[codex] refine token routes workspace motion and hierarchy (#394)

* refine token routes workspace motion and hierarchy

* address route workspace review feedback

* Refine token route density and motion

* [codex] refine token routes motion behavior

* [codex] address route motion review comments

* [codex] tighten route row interaction states
This commit is contained in:
Cita
2026-04-03 11:04:40 +08:00
committed by GitHub
parent af5b62fd8b
commit bc3893ff3b
14 changed files with 2475 additions and 738 deletions
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
vi.mock('@dnd-kit/sortable', async () => {
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable');
return {
...actual,
useSortable: () => ({
attributes: { 'data-sortable-handle': 'true' },
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: { x: 0, y: 18, scaleX: 0.82, scaleY: 1.24 },
transition: 'transform 200ms ease',
isDragging: false,
}),
};
});
import RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow {
return {
id: 42,
modelPattern: 'gpt-5.4',
displayName: 'gpt-5.4',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 3,
enabledChannelCount: 3,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
...overrides,
};
}
function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
return {
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.4',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
...overrides,
};
}
describe('RouteCard sortable shell', () => {
it('applies sortable transform to the outer channel shell instead of the inner row card', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 1 }),
]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const shell = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-shell'
&& node.props['data-channel-id'] === 11
));
const innerRow = shell.find((node) => (
node.type === 'div'
&& node.props.style
&& node.props.style.borderRadius === 14
&& collectText(node).includes('user_a')
));
expect(shell.props.style.transform).toContain('translate3d');
expect(shell.props.style.transform).not.toContain('scale');
expect(String(shell.props.style.transition || '')).toContain('transform');
expect(innerRow.props.style.transform).toBeUndefined();
});
});
+548 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { SortableContext } from '@dnd-kit/sortable';
import RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js';
import { getRouteRoutingStrategyDescription } from './routingStrategy.js';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
@@ -97,15 +100,74 @@ describe('RouteCard', () => {
));
expect(regexBadge.props.style).toMatchObject({
maxWidth: 180,
flex: '0 1 116px',
maxWidth: 116,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexShrink: 1,
});
});
it('gives the collapsed group title more layout priority than the regex badge', () => {
const root = create(
<RouteCard
route={buildRoute({
displayName: 'minimax m2.1 群组名称',
})}
brand={null}
expanded={false}
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={undefined}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const titleRow = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'collapsed-route-title-row'
));
const titleNode = titleRow.find((node) => (
node.type === 'code'
&& collectText(node) === 'minimax m2.1 群组名称'
));
const regexBadge = titleRow.find((node) => (
node.type === 'span'
&& typeof node.props.className === 'string'
&& node.props.className.includes('badge-muted')
&& collectText(node) === LONG_REGEX_PATTERN
));
expect(titleNode.props.style.flex).toBe('1 1 180px');
expect(regexBadge.props.style.flex).toBe('0 1 116px');
expect(regexBadge.props.style.maxWidth).toBe(116);
});
it('renders a clear cooldown action on expanded cards', () => {
const onClearCooldown = vi.fn();
const root = create(
@@ -154,6 +216,60 @@ describe('RouteCard', () => {
expect(onClearCooldown).toHaveBeenCalledTimes(1);
});
it('lets keyboard users toggle the collapsed summary card', () => {
const onToggleExpand = vi.fn();
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded={false}
summaryExpanded={false}
onToggleExpand={onToggleExpand}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={undefined}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const summaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
expect(summaryCard.props.role).toBe('button');
expect(summaryCard.props.tabIndex).toBe(0);
expect(summaryCard.props['aria-expanded']).toBe(false);
summaryCard.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
summaryCard.props.onKeyDown({ key: ' ', preventDefault: vi.fn() });
expect(onToggleExpand).toHaveBeenCalledTimes(2);
});
it('renders desktop priority rail summaries for multiple channel layers', () => {
const root = create(
<RouteCard
@@ -229,4 +345,433 @@ describe('RouteCard', () => {
expect(p1RailNode.props.style.background).not.toBe('var(--color-bg)');
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color);
});
it('uses translate-only rect sorting for flat channel shell rows', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 1 }),
]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const sortableContext = root.root.findByType(SortableContext);
expect(sortableContext.props.strategy).toBe(translateOnlyRectSortingStrategy);
});
it('renders desktop channel rows in sortable shell order within a single sortable list', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 0 }),
buildChannel({ id: 21, accountId: 103, tokenId: 1003, priority: 1 }),
]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const sortableList = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-sortable-list'
));
const directShells = sortableList.findAll((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-shell'
));
expect(directShells.map((child) => child.props['data-channel-id'])).toEqual([11, 12, 21]);
});
it('omits long explanatory copy in compact detail panels', () => {
const root = create(
<RouteCard
route={buildRoute({
modelPattern: 'gpt-4o-*',
displayName: 'gpt-4o',
})}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const detailText = collectText(root.root);
expect(detailText).not.toContain('通配符路由按请求实时决策');
expect(detailText).not.toContain(getRouteRoutingStrategyDescription('weighted'));
});
it('places compact route strategy and add channel controls on the same row', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const compactActionRow = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-action-row'
));
const strategySelectWrap = compactActionRow.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-strategy-select'
));
const addChannelButton = compactActionRow.find((node) => (
node.type === 'button'
&& collectText(node).includes('添加通道')
));
expect(compactActionRow.props.style.flexDirection).toBe('row');
expect(compactActionRow.props.style.justifyContent).toBe('flex-start');
expect(collectText(compactActionRow)).toContain('路由策略');
expect(collectText(compactActionRow)).toContain('添加通道');
expect(strategySelectWrap.props.style.flex).toBe('0 0 168px');
expect(addChannelButton.props.style.marginLeft).toBe('auto');
});
it('keeps compact status badges inline with the route name', () => {
const root = create(
<RouteCard
route={buildRoute({
modelPattern: 'gpt-5.2-codex',
channelCount: 16,
})}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
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()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const compactHeaderMain = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-header-main'
));
expect(compactHeaderMain.props.style.flexDirection).toBe('row');
expect(collectText(compactHeaderMain)).toContain('gpt-5.2-codex');
expect(collectText(compactHeaderMain)).toContain('启用');
expect(collectText(compactHeaderMain)).toContain('16 通道');
});
it('skips collapsed rerenders when only expanded-channel state changes', () => {
const routeTarget = buildRoute();
let modelPatternReadCount = 0;
const route = new Proxy(routeTarget, {
get(target, property, receiver) {
if (property === 'modelPattern') {
modelPatternReadCount += 1;
}
return Reflect.get(target, property, receiver);
},
}) as RouteSummaryRow;
const callbacks = {
onToggleExpand: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onToggleEnabled: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const candidateView = { routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} };
const renderCard = (channelTokenDraft: Record<number, number>, updatingChannel: Record<number, boolean>) => (
<RouteCard
route={route}
brand={null}
expanded={false}
onToggleExpand={callbacks.onToggleExpand}
onEdit={callbacks.onEdit}
onDelete={callbacks.onDelete}
onToggleEnabled={callbacks.onToggleEnabled}
onClearCooldown={callbacks.onClearCooldown}
clearingCooldown={false}
onRoutingStrategyChange={callbacks.onRoutingStrategyChange}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={candidateView}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
savingPriority={false}
onTokenDraftChange={callbacks.onTokenDraftChange}
onSaveToken={callbacks.onSaveToken}
onDeleteChannel={callbacks.onDeleteChannel}
onToggleChannelEnabled={callbacks.onToggleChannelEnabled}
onChannelDragEnd={callbacks.onChannelDragEnd}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={callbacks.onCreateTokenForMissing}
onAddChannel={callbacks.onAddChannel}
onSiteBlockModel={callbacks.onSiteBlockModel}
expandedSourceGroupMap={{}}
onToggleSourceGroup={callbacks.onToggleSourceGroup}
/>
);
let root!: WebTestRenderer;
act(() => {
root = create(renderCard({}, {}));
});
const initialReadCount = modelPatternReadCount;
act(() => {
root.update(renderCard({ 11: 1001 }, { 11: true }));
});
expect(modelPatternReadCount).toBe(initialReadCount);
});
it('skips collapsed rerenders when only expanded-only callback identities change', () => {
const routeTarget = buildRoute();
let modelPatternReadCount = 0;
const route = new Proxy(routeTarget, {
get(target, property, receiver) {
if (property === 'modelPattern') {
modelPatternReadCount += 1;
}
return Reflect.get(target, property, receiver);
},
}) as RouteSummaryRow;
const callbacksA = {
onToggleExpand: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onToggleEnabled: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const callbacksB = {
...callbacksA,
onEdit: vi.fn(),
onDelete: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const candidateView = { routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} };
const renderCard = (callbacks: typeof callbacksA) => (
<RouteCard
route={route}
brand={null}
expanded={false}
onToggleExpand={callbacks.onToggleExpand}
onEdit={callbacks.onEdit}
onDelete={callbacks.onDelete}
onToggleEnabled={callbacks.onToggleEnabled}
onClearCooldown={callbacks.onClearCooldown}
clearingCooldown={false}
onRoutingStrategyChange={callbacks.onRoutingStrategyChange}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={candidateView}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={callbacks.onTokenDraftChange}
onSaveToken={callbacks.onSaveToken}
onDeleteChannel={callbacks.onDeleteChannel}
onToggleChannelEnabled={callbacks.onToggleChannelEnabled}
onChannelDragEnd={callbacks.onChannelDragEnd}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={callbacks.onCreateTokenForMissing}
onAddChannel={callbacks.onAddChannel}
onSiteBlockModel={callbacks.onSiteBlockModel}
expandedSourceGroupMap={{}}
onToggleSourceGroup={callbacks.onToggleSourceGroup}
/>
);
const root = create(renderCard(callbacksA));
const initialReadCount = modelPatternReadCount;
act(() => {
root.update(renderCard(callbacksB));
});
expect(modelPatternReadCount).toBe(initialReadCount);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
import { act, create } from 'react-test-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import RouteFilterBar from './RouteFilterBar.js';
function renderBar(collapsed: boolean) {
return (
<RouteFilterBar
totalRouteCount={3}
activeBrand={null}
setActiveBrand={vi.fn()}
activeSite={null}
setActiveSite={vi.fn()}
activeEndpointType={null}
setActiveEndpointType={vi.fn()}
activeGroupFilter={null}
setActiveGroupFilter={vi.fn()}
enabledFilter="all"
setEnabledFilter={vi.fn()}
enabledCounts={{ enabled: 2, disabled: 1 }}
brandList={{ list: [], otherCount: 0 }}
siteList={[]}
endpointTypeList={[]}
groupRouteList={[]}
collapsed={collapsed}
onToggle={vi.fn()}
/>
);
}
describe('RouteFilterBar', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('uses the shared collapse presence wrapper while expanded', () => {
const root = create(renderBar(false));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedPanel = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(String(presence.props.className)).toContain('anim-collapse');
expect(String(presence.props.className)).toContain('is-open');
expect(String(expandedPanel.props.className)).not.toContain('is-closing');
});
it('keeps the collapse wrapper mounted while collapsed so expand can animate from existing layout', () => {
const root = create(renderBar(true));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
});
it('keeps expanded controls mounted briefly while closing, then removes them after the collapse window', () => {
vi.useFakeTimers();
const root = create(renderBar(false));
act(() => {
root.update(renderBar(true));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
const expandedWhileClosing = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedWhileClosing).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(181);
});
const expandedAfterWindow = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedAfterWindow).toHaveLength(0);
});
it('mounts expanded controls before flipping the presence wrapper open so expand does not start from an empty shell', () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal('window', {
requestAnimationFrame: (callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
cancelAnimationFrame: vi.fn(),
});
const root = create(renderBar(true));
act(() => {
root.update(renderBar(false));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedBeforeOpen = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedBeforeOpen).toHaveLength(1);
expect(String(presence.props.className)).not.toContain('is-open');
expect(rafCallbacks).toHaveLength(1);
act(() => {
rafCallbacks[0](0);
});
const openPresence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(openPresence.props.className)).toContain('is-open');
});
});
+189 -161
View File
@@ -1,12 +1,13 @@
import type { ReactNode } from 'react';
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react';
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
const FILTER_EXPANDED_CONTENT_UNMOUNT_MS = 180;
type RouteFilterBarProps = {
totalRouteCount: number;
activeBrand: string | null;
@@ -104,8 +105,31 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
collapsed,
onToggle,
} = props;
const [renderExpandedContent, setRenderExpandedContent] = useState(!collapsed);
const [presenceOpen, setPresenceOpen] = useState(!collapsed);
const expandPresence = useAnimatedVisibility(!collapsed, 220);
useEffect(() => {
if (!collapsed) return undefined;
const timerId = globalThis.setTimeout(() => setRenderExpandedContent(false), FILTER_EXPANDED_CONTENT_UNMOUNT_MS);
return () => globalThis.clearTimeout(timerId);
}, [collapsed]);
useLayoutEffect(() => {
if (collapsed) {
setPresenceOpen(false);
return undefined;
}
setRenderExpandedContent(true);
setPresenceOpen(false);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setPresenceOpen(true));
return () => window.cancelAnimationFrame(rafId);
}
setPresenceOpen(true);
return undefined;
}, [collapsed]);
return (
<div className="route-filter-bar">
@@ -133,168 +157,172 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
</button>
{/* Expanded panel */}
{expandPresence.shouldRender && (
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
active={enabledFilter === 'all'}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setEnabledFilter('all')}
/>
<FilterChip
active={enabledFilter === 'enabled'}
label={tr('仅启用')}
count={enabledCounts.enabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-success)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')}
/>
<FilterChip
active={enabledFilter === 'disabled'}
label={tr('仅禁用')}
count={enabledCounts.disabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')}
/>
</FilterRow>
{/* Brand row */}
<FilterRow label={tr('品牌')}>
<FilterChip
active={!activeBrand}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setActiveBrand(null)}
/>
{brandList.list.map(([brandName, { count, brand }]) => (
<FilterChip
key={brandName}
active={activeBrand === brandName}
label={brandName}
count={count}
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
/>
))}
{brandList.otherCount > 0 && (
<FilterChip
active={activeBrand === '__other__'}
label={tr('其他')}
count={brandList.otherCount}
icon={<span style={{ fontSize: 10 }}>?</span>}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
/>
)}
</FilterRow>
{/* Site row */}
{siteList.length > 0 && (
<FilterRow label={tr('站点')}>
<FilterChip
active={!activeSite}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚡</span>}
onClick={() => setActiveSite(null)}
/>
{siteList.map(([siteName, { count }]) => (
<div className={`anim-collapse route-filter-bar-presence ${presenceOpen ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{renderExpandedContent && (
<div className="route-filter-bar-expanded">
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
key={siteName}
active={activeSite === siteName}
label={siteName}
count={count}
icon={
<span
style={{
fontSize: 8,
background: hashColor(siteName),
color: 'white',
borderRadius: 3,
padding: '1px 2px',
lineHeight: 1,
}}
>
{siteAvatarLetters(siteName)}
</span>
}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
active={enabledFilter === 'all'}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setEnabledFilter('all')}
/>
))}
</FilterRow>
<FilterChip
active={enabledFilter === 'enabled'}
label={tr('仅启用')}
count={enabledCounts.enabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-success)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')}
/>
<FilterChip
active={enabledFilter === 'disabled'}
label={tr('仅禁用')}
count={enabledCounts.disabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')}
/>
</FilterRow>
{/* Brand row */}
<FilterRow label={tr('品牌')}>
<FilterChip
active={!activeBrand}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setActiveBrand(null)}
/>
{brandList.list.map(([brandName, { count, brand }]) => (
<FilterChip
key={brandName}
active={activeBrand === brandName}
label={brandName}
count={count}
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
/>
))}
{brandList.otherCount > 0 && (
<FilterChip
active={activeBrand === '__other__'}
label={tr('其他')}
count={brandList.otherCount}
icon={<span style={{ fontSize: 10 }}>?</span>}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
/>
)}
</FilterRow>
{/* Site row */}
{siteList.length > 0 && (
<FilterRow label={tr('站点')}>
<FilterChip
active={!activeSite}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚡</span>}
onClick={() => setActiveSite(null)}
/>
{siteList.map(([siteName, { count }]) => (
<FilterChip
key={siteName}
active={activeSite === siteName}
label={siteName}
count={count}
icon={
<span
style={{
fontSize: 8,
background: hashColor(siteName),
color: 'white',
borderRadius: 3,
padding: '1px 2px',
lineHeight: 1,
}}
>
{siteAvatarLetters(siteName)}
</span>
}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
/>
))}
</FilterRow>
)}
{/* Group row */}
<FilterRow label={tr('群组')}>
<FilterChip
active={activeGroupFilter === '__all__'}
label={tr('全部群组')}
count={groupRouteList.length}
icon={<span style={{ fontSize: 10 }}>◎</span>}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
/>
{groupRouteList.map((groupRoute) => (
<FilterChip
key={groupRoute.id}
active={activeGroupFilter === groupRoute.id}
label={groupRoute.title}
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
icon={
groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
))}
</FilterRow>
{/* Endpoint type row */}
<FilterRow label={tr('能力')}>
<FilterChip
active={!activeEndpointType}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(null)}
/>
{endpointTypeList.map(([endpointType, count]) => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
return (
<FilterChip
key={endpointType}
active={activeEndpointType === endpointType}
label={endpointType}
count={count}
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
/>
);
})}
{endpointTypeList.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
)}
</FilterRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', border: '1px solid var(--color-border)' }}
onClick={onToggle}
>
{tr('收起筛选面板')}
</button>
</div>
</div>
)}
{/* Group row */}
<FilterRow label={tr('群组')}>
<FilterChip
active={activeGroupFilter === '__all__'}
label={tr('全部群组')}
count={groupRouteList.length}
icon={<span style={{ fontSize: 10 }}>◎</span>}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
/>
{groupRouteList.map((groupRoute) => (
<FilterChip
key={groupRoute.id}
active={activeGroupFilter === groupRoute.id}
label={groupRoute.title}
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
icon={
groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
))}
</FilterRow>
{/* Endpoint type row */}
<FilterRow label={tr('能力')}>
<FilterChip
active={!activeEndpointType}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(null)}
/>
{endpointTypeList.map(([endpointType, count]) => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
return (
<FilterChip
key={endpointType}
active={activeEndpointType === endpointType}
label={endpointType}
count={count}
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
/>
);
})}
{endpointTypeList.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
)}
</FilterRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', border: '1px solid var(--color-border)' }}
onClick={onToggle}
>
{tr('收起筛选面板')}
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -68,10 +68,85 @@ describe('SortableChannelRow layering', () => {
const row = root.root.find((node) => (
node.type === 'div'
&& node.props.style
&& node.props.style.borderLeft === '2px solid var(--color-primary)'
&& node.props['data-layer-root']
));
expect(row.props.style.zIndex).toBeUndefined();
expect(row.props.style.borderLeft).toBeUndefined();
});
it('disables row tooltips while a drag interaction is in progress', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
dragInProgress
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const tooltipNodes = root.root.findAll((node) => node.props['data-tooltip'] !== undefined);
expect(tooltipNodes).toHaveLength(0);
});
it('treats channel-management-disabled rows as non-interactive for the drag handle', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
channelManagementDisabled
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const dragHandle = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '拖拽调整优先级桶'
));
expect(dragHandle.props.disabled).toBe(true);
expect(dragHandle.props['data-tooltip']).toBe('该路由当前不可编辑优先级');
});
});
+115 -181
View File
@@ -1,6 +1,4 @@
import { useState, type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js';
import {
@@ -15,12 +13,16 @@ export function SortableChannelRow({
channel,
displayPriority,
showPriorityBadge = true,
dragging = false,
dragHandleProps,
dragHandleRef,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
readOnly = false,
channelManagementDisabled = false,
dragInProgress = false,
mobile = false,
tokenOptions,
activeTokenId,
@@ -31,37 +33,48 @@ export function SortableChannelRow({
onToggleEnabled,
onSiteBlockModel,
}: SortableChannelRowProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: channel.id,
disabled: isSavingPriority || readOnly,
});
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled;
const suppressTooltips = dragInProgress || dragging;
const rowTransition = [
'box-shadow 180ms ease',
'background-color 180ms ease',
'border-color 180ms ease',
'opacity 180ms ease',
].filter(Boolean).join(', ');
const dragHandleStyle: CSSProperties = {
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 34%, var(--color-border-light))' : 'var(--color-border-light)'}`,
borderRadius: 10,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 80%, var(--color-info) 20%)'
: 'color-mix(in srgb, var(--color-bg-card) 90%, white 10%)',
boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.62)',
color: dragging ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
cursor: isSavingPriority || managementLocked ? 'not-allowed' : 'grab',
opacity: managementLocked ? 0.65 : 1,
transition: 'background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease',
};
const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
transition: rowTransition || undefined,
opacity: dragging ? 0.92 : channel.enabled === false ? 0.56 : 1,
display: 'grid',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: 8,
padding: mobile ? '10px 12px' : '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderBottom: '1px solid var(--color-border)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: isDragging ? 'rgba(59,130,246,0.08)' : 'var(--color-bg-card, rgba(79,70,229,0.02))',
boxShadow: isDragging ? 'var(--shadow-sm)' : 'none',
gap: mobile ? 8 : 6,
padding: mobile ? '8px 9px' : '5px 8px',
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 38%, var(--color-border-light))' : 'color-mix(in srgb, var(--color-border-light) 92%, transparent)'}`,
borderRadius: 14,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 82%, var(--color-info) 18%)'
: 'color-mix(in srgb, var(--color-bg-card) 96%, white 4%)',
boxShadow: dragging
? '0 18px 34px rgba(15, 23, 42, 0.12)'
: '0 10px 22px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.7)',
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
@@ -79,27 +92,19 @@ export function SortableChannelRow({
if (mobile) {
return (
<div ref={setNodeRef} style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div data-layer-root style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority || readOnly}
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
marginTop: 2,
...dragHandleStyle,
}}
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -112,8 +117,8 @@ export function SortableChannelRow({
</svg>
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
{showPriorityBadge ? (
<span
className="badge"
@@ -143,7 +148,7 @@ export function SortableChannelRow({
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
@@ -161,14 +166,14 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
@@ -183,22 +188,22 @@ export function SortableChannelRow({
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
@@ -209,12 +214,12 @@ export function SortableChannelRow({
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -239,7 +244,7 @@ export function SortableChannelRow({
</div>
{!managementLocked && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 6, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
@@ -260,12 +265,12 @@ export function SortableChannelRow({
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
@@ -306,26 +311,16 @@ export function SortableChannelRow({
}
return (
<div ref={setNodeRef} style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
<div data-layer-root style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 6, fontSize: 12, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority || readOnly}
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
}}
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
style={dragHandleStyle}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -377,14 +372,14 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
@@ -399,7 +394,7 @@ export function SortableChannelRow({
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
@@ -409,15 +404,15 @@ export function SortableChannelRow({
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
@@ -428,12 +423,12 @@ export function SortableChannelRow({
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -455,9 +450,9 @@ export function SortableChannelRow({
</div>
{!managementLocked ? (
mobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<div style={{ width: '100%' }}>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
@@ -477,107 +472,46 @@ export function SortableChannelRow({
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
>
站点屏蔽
</button>
) : null}
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={suppressTooltips ? undefined : (channel.enabled === false ? '启用此通道' : '禁用此通道')}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={suppressTooltips ? undefined : `将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
站点屏蔽
</button>
</div>
) : null}
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={channel.enabled === false ? '启用此通道' : '禁用此通道'}
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
{channel.enabled === false ? '启用' : '禁用'}
移除
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={`将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
站点屏蔽
</button>
) : null}
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
</>
)
</div>
</>
) : null}
</div>
);
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
describe('translateOnlyRectSortingStrategy', () => {
it('uses rect deltas without introducing scale for variable-height rows', () => {
const rects = [
{ top: 0, left: 0, width: 200, height: 80, right: 200, bottom: 80 },
{ top: 92, left: 0, width: 200, height: 56, right: 200, bottom: 148 },
{ top: 160, left: 0, width: 200, height: 120, right: 200, bottom: 280 },
];
const siblingTransform = translateOnlyRectSortingStrategy({
rects,
activeIndex: 0,
overIndex: 2,
index: 1,
activeNodeRect: rects[0],
});
expect(siblingTransform).toEqual({
x: 0,
y: -92,
scaleX: 1,
scaleY: 1,
});
});
});
@@ -0,0 +1,23 @@
import { arrayMove, type SortingStrategy } from '@dnd-kit/sortable';
export const translateOnlyRectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
const newRects = arrayMove(rects, overIndex, activeIndex);
const oldRect = rects[index];
const newRect = newRects[index];
if (!newRect || !oldRect) {
return null;
}
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: 1,
scaleY: 1,
};
};
+5 -1
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import type { ButtonHTMLAttributes, ReactNode, RefCallback } from 'react';
import type { BrandInfo } from '../../components/BrandIcon.js';
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
@@ -127,12 +127,16 @@ export type SortableChannelRowProps = {
channel: RouteChannel;
displayPriority?: number;
showPriorityBadge?: boolean;
dragging?: boolean;
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>;
dragHandleRef?: RefCallback<HTMLButtonElement>;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
channelManagementDisabled?: boolean;
dragInProgress?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;