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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('该路由当前不可编辑优先级');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user