- {!editingLegacyPatternGroup && (
-
-
-
- )}
-
-
-
-
-
-
-
- {isExactModelPattern(form.modelPattern)
- ? tr('当前为精确模型匹配。')
- : tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
-
-
- {modelPatternError && (
-
- {modelPatternError}
+
+ {isExactModelPattern(form.modelPattern)
+ ? tr('当前为精确模型匹配。')
+ : tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
- )}
- {form.modelPattern.trim() && !modelPatternError && (
-
-
- {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
+ {modelPatternError && (
+
+ {modelPatternError}
+ )}
- {previewModelSamples.length === 0 ? (
-
- {tr('当前暂无可预览模型,请先同步模型。')}
+ {form.modelPattern.trim() && !modelPatternError && (
+
+
+ {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
- ) : previewMatchedModels.length === 0 ? (
-
- {tr('当前规则未命中任何样本模型。')}
-
- ) : (
-
- {previewMatchedModels.slice(0, 12).map((modelName) => (
-
- {modelName}
-
- ))}
-
- )}
- {previewMatchedModels.length > 12 && (
-
- {tr('仅展示前 12 个命中样本。')}
-
- )}
+ {previewModelSamples.length === 0 ? (
+
+ {tr('当前暂无可预览模型,请先同步模型。')}
+
+ ) : previewMatchedModels.length === 0 ? (
+
+ {tr('当前规则未命中任何样本模型。')}
+
+ ) : (
+
+ {previewMatchedModels.slice(0, 12).map((modelName) => (
+
+ {modelName}
+
+ ))}
+
+ )}
+
+ {previewMatchedModels.length > 12 && (
+
+ {tr('仅展示前 12 个命中样本。')}
+
+ )}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ {`已选择 ${sourcePickerSelection.length} 个来源模型`}
+
+ {`候选 ${filteredSourceRoutes.length} / ${exactSourceRouteOptions.length}`}
+
+
+
+
+
+
+
+ {filteredSourceRoutes.length === 0 ? (
+
+ {exactSourceRouteOptions.length === 0
+ ? tr('当前没有可选的精确模型路由。')
+ : tr('没有匹配的来源模型。')}
+
+ ) : (
+ filteredSourceRoutes.map((route) => {
+ const selected = sourcePickerSelection.includes(route.id);
+ const label = renderRouteOptionLabel(route);
+ return (
+
+ );
+ })
)}
- )}
-
-
+
+
+ >
);
}
diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx
index 854524d..e75646d 100644
--- a/src/web/pages/token-routes/RouteCard.tsx
+++ b/src/web/pages/token-routes/RouteCard.tsx
@@ -176,11 +176,11 @@ function RouteCardInner({
) : routeIcon.kind === 'text' ? (
{routeIcon.value}
- ) : brand ? (
+ ) : routeIcon.kind === 'auto' && brand ? (
- ) : (
+ ) : routeIcon.kind === 'auto' ? (
- )}
+ ) : null}
@@ -245,11 +245,11 @@ function RouteCardInner({
{routeIcon.value}
- ) : brand ? (
+ ) : routeIcon.kind === 'auto' && brand ? (
- ) : (
+ ) : routeIcon.kind === 'auto' ? (
- )}
+ ) : null}
{title}
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx
index b0bf30a..bb86f6d 100644
--- a/src/web/pages/token-routes/RouteFilterBar.tsx
+++ b/src/web/pages/token-routes/RouteFilterBar.tsx
@@ -209,11 +209,11 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
) : groupRoute.icon.kind === 'text' ? (
{groupRoute.icon.value}
- ) : groupRoute.brand ? (
+ ) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
- ) : (
+ ) : groupRoute.icon.kind === 'auto' ? (
- )
+ ) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts
index 2977b5f..2745e84 100644
--- a/src/web/pages/token-routes/types.ts
+++ b/src/web/pages/token-routes/types.ts
@@ -161,7 +161,7 @@ export type SortableChannelRowProps = {
export type GroupRouteItem = {
id: number;
title: string;
- icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
+ icon: { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null;
modelPattern: string;
channelCount: number;
diff --git a/src/web/pages/token-routes/utils.test.ts b/src/web/pages/token-routes/utils.test.ts
new file mode 100644
index 0000000..032130e
--- /dev/null
+++ b/src/web/pages/token-routes/utils.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../components/BrandIcon.js', () => ({
+ getBrand: () => null,
+ normalizeBrandIconKey: (icon: string) => icon.trim().toLowerCase(),
+}));
+
+import {
+ ROUTE_ICON_NONE_VALUE,
+ normalizeRouteDisplayIconValue,
+ resolveRouteIcon,
+} from './utils.js';
+
+describe('token route icon helpers', () => {
+ it('preserves the explicit no-icon sentinel during normalization', () => {
+ expect(normalizeRouteDisplayIconValue(ROUTE_ICON_NONE_VALUE)).toBe(ROUTE_ICON_NONE_VALUE);
+ });
+
+ it('treats the explicit no-icon sentinel as no icon', () => {
+ expect(resolveRouteIcon({ displayIcon: ROUTE_ICON_NONE_VALUE })).toEqual({ kind: 'none' });
+ });
+});
diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts
index cfec687..9e6da8b 100644
--- a/src/web/pages/token-routes/utils.ts
+++ b/src/web/pages/token-routes/utils.ts
@@ -5,6 +5,7 @@ import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionSta
export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
+export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record
= {
openai: 'chatgpt',
@@ -172,8 +173,13 @@ export function parseBrandIconValue(raw: string): string | null {
return normalizeBrandIconKey(icon);
}
+export function isRouteIconNoneValue(raw: string | null | undefined): boolean {
+ return (raw || '').trim() === ROUTE_ICON_NONE_VALUE;
+}
+
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
+ if (isRouteIconNoneValue(normalized)) return ROUTE_ICON_NONE_VALUE;
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
@@ -212,9 +218,10 @@ export function siteAvatarLetters(siteName: string): string {
return compact.slice(0, 2).toUpperCase();
}
-export function resolveRouteIcon(route: Pick): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
+export function resolveRouteIcon(route: Pick): { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
- if (!icon) return { kind: 'none' };
+ if (isRouteIconNoneValue(icon)) return { kind: 'none' };
+ if (!icon) return { kind: 'auto' };
const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon };
diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx
index 6295bbf..3c99b35 100644
--- a/src/web/pages/tokenRoutes.group-collapse.test.tsx
+++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
+import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
@@ -55,6 +56,15 @@ function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string
));
}
+function findCheckboxByLabelText(root: ReactTestInstance, text: string): ReactTestInstance {
+ return root.find((node) => (
+ node.type === 'input'
+ && node.props.type === 'checkbox'
+ && !!node.parent
+ && collectText(node.parent).includes(text)
+ ));
+}
+
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
@@ -855,7 +865,7 @@ describe('TokenRoutes grouped source models', () => {
}
});
- it('opens a modal-based group creator and submits explicit-group sourceRouteIds', async () => {
+ it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
@@ -896,12 +906,26 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
+
+ await act(async () => {
+ findButtonByText(root.root, '选择来源模型').props.onClick();
+ });
+ await flushMicrotasks();
+
+ expect(findInputByPlaceholder(root.root, '搜索来源模型')).toBeTruthy();
+
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
+ await act(async () => {
+ findButtonByText(root.root, '确认选择').props.onClick();
+ });
+ await flushMicrotasks();
+
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
@@ -918,6 +942,76 @@ describe('TokenRoutes grouped source models', () => {
}
});
+ it('saves explicit groups with auto brand icon disabled as a no-icon sentinel', async () => {
+ apiMock.getRoutesSummary.mockResolvedValue([
+ {
+ id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
+ displayIcon: null, modelMapping: null, enabled: true,
+ routeMode: 'pattern', sourceRouteIds: [],
+ channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
+ decisionSnapshot: null, decisionRefreshedAt: null,
+ },
+ ]);
+
+ let root: ReturnType | null = null;
+ try {
+ await act(async () => {
+ root = create(
+
+
+
+
+ ,
+ );
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findButtonByText(root.root, '新建群组').props.onClick();
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findButtonByText(root.root, '选择来源模型').props.onClick();
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findButtonByText(root.root, '确认选择').props.onClick();
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findCheckboxByLabelText(root.root, '自动品牌图标').props.onChange({ target: { checked: false } });
+ });
+ await flushMicrotasks();
+
+ await act(async () => {
+ findButtonByText(root.root, '创建群组').props.onClick();
+ });
+ await flushMicrotasks();
+
+ expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
+ routeMode: 'explicit_group',
+ displayName: 'claude-opus-4-6',
+ sourceRouteIds: [11],
+ displayIcon: ROUTE_ICON_NONE_VALUE,
+ }));
+ } finally {
+ root?.unmount();
+ }
+ });
+
it('edits legacy regex groups in advanced mode only', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
@@ -1045,11 +1139,21 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ await act(async () => {
+ findButtonByText(root.root, '选择来源模型').props.onClick();
+ });
+ await flushMicrotasks();
+
await act(async () => {
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
+ await act(async () => {
+ findButtonByText(root.root, '确认选择').props.onClick();
+ });
+ await flushMicrotasks();
+
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
});